diff --git a/assets/animations/svg_skins/cat_default.svg b/assets/animations/svg_skins/cat_default.svg new file mode 100644 index 0000000..661c129 --- /dev/null +++ b/assets/animations/svg_skins/cat_default.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/core/animation_manager.py b/src/core/animation_manager.py index faa829b..5906cec 100644 --- a/src/core/animation_manager.py +++ b/src/core/animation_manager.py @@ -146,6 +146,11 @@ def update_frame(self): # Бешеная тряска + увеличение painter.scale(1.2, 1.2) painter.translate(random.randint(-4, 4), random.randint(-4, 4)) + elif self.current_state == "shaking": + # Сильное встряхивание + динамический масштаб + scale = 1.0 + random.uniform(-0.08, 0.08) + painter.scale(scale, scale) + painter.translate(random.randint(-6, 6), random.randint(-6, 6)) elif self.current_state == "stretching": # Растягивание painter.scale(0.8, 1.4) @@ -154,6 +159,11 @@ def update_frame(self): scale_y = 1.0 + 0.1 * abs(math.sin(self.frame_counter * 0.8)) painter.translate(0, 10 * (scale_y - 1.0)) painter.scale(1.0, scale_y) + elif self.current_state == "playing": + # Покачивание из стороны в сторону и легкое подпрыгивание + angle = 8 * math.sin(self.frame_counter * 0.6) + painter.rotate(angle) + painter.translate(0, -abs(5 * math.sin(self.frame_counter * 0.8))) elif self.current_state == "thinking": # Наклон + покачивание painter.rotate(10 + 5 * math.sin(self.frame_counter * 0.2)) diff --git a/src/core/input_manager.py b/src/core/input_manager.py index 9ca9d10..b3b727b 100644 --- a/src/core/input_manager.py +++ b/src/core/input_manager.py @@ -44,6 +44,8 @@ def stop(self): self.keyboard_listener.stop() class InputManager(QObject): + laser_mode_changed = Signal(bool) + def __init__(self, pet_window, data_store=None): super().__init__() self.window = pet_window @@ -404,14 +406,17 @@ def handle_mouse(self, x, y): # Проверка "поглаживания" pet_pos = self.window.get_cached_pos() - center_x = pet_pos.x() + self.window.width() // 2 - center_y = pet_pos.y() + self.window.height() // 2 + pet_width = self.window.width() if self.window else 100 + pet_height = self.window.height() if self.window else 100 + center_x = pet_pos.x() + pet_width // 2 + center_y = pet_pos.y() + pet_height // 2 dx_pet = x - center_x dy_pet = y - center_y dist_sq_pet = dx_pet * dx_pet + dy_pet * dy_pet - # Оптимизация: сравнение квадрата расстояния (порог 60px -> 3600) - if dist_sq_pet < 3600: + # Динамический радиус поглаживания на основе размера питомца (по умолчанию 60px при размере 100px) + pet_radius = max(30, int(pet_width * 0.6)) + if dist_sq_pet < pet_radius * pet_radius: # Исключаем пассивный фарм (требуем активное поглаживание: активное движение мыши и кулдаун) if self.last_pet_time == 0: self.last_pet_mouse_pos = (x, y) @@ -470,6 +475,7 @@ def toggle_laser_mode(self): else: self.window.setCursor(Qt.ArrowCursor) self.window.animation_manager.play_state("idle") + self.laser_mode_changed.emit(self.laser_mode) return self.laser_mode def reset_all_data(self): diff --git a/src/ui/main_window.py b/src/ui/main_window.py index e619905..a3a9532 100644 --- a/src/ui/main_window.py +++ b/src/ui/main_window.py @@ -55,7 +55,8 @@ def __init__(self, config_manager=None): self.is_dragging = False self.shake_count = 0 self.last_shake_time = 0 - self.original_size = QSize(100, 100) + pet_size = self.config.get("pet_size") if self.config else 100 + self.original_size = QSize(pet_size, pet_size) # Начальный размер self.resize(self.original_size) @@ -91,6 +92,14 @@ def set_opacity(self, value): """Устанавливает прозрачность окна (0-100)""" self.setWindowOpacity(value / 100.0) + def set_pet_size(self, size_px): + """Устанавливает базовый размер питомца в пикселях (50-250)""" + if self.config: + self.config.set("pet_size", size_px) + self.original_size = QSize(size_px, size_px) + self.resize(self.original_size) + self.animation_manager.update_size(self.size()) + def set_always_on_top(self, enabled): """Включает или выключает режим 'Поверх всех окон' динамически""" if self.config: diff --git a/src/ui/settings_dialog.py b/src/ui/settings_dialog.py index 5339824..12827ed 100644 --- a/src/ui/settings_dialog.py +++ b/src/ui/settings_dialog.py @@ -52,6 +52,22 @@ def __init__(self, config, parent=None): self.opacity_slider.valueChanged.connect(lambda v: self.opacity_val_label.setText(f"{v}%")) layout.addWidget(self.opacity_slider) + # Размер питомца + size_header_layout = QHBoxLayout() + size_header_layout.addWidget(QLabel("Размер питомца (px):")) + pet_size_val = self.config.get("pet_size") or 100 + self.size_val_label = QLabel(f"{pet_size_val}px") + self.size_val_label.setStyleSheet("font-weight: bold; color: #555;") + size_header_layout.addStretch() + size_header_layout.addWidget(self.size_val_label) + layout.addLayout(size_header_layout) + + self.size_slider = QSlider(Qt.Horizontal) + self.size_slider.setRange(50, 250) + self.size_slider.setValue(pet_size_val) + self.size_slider.valueChanged.connect(lambda v: self.size_val_label.setText(f"{v}px")) + layout.addWidget(self.size_slider) + # Интервал растяжки layout.addWidget(QLabel("Интервал растяжки (мин):")) self.stretch_spin = QSpinBox() @@ -168,6 +184,7 @@ def save_settings(self): self.config.set("always_on_top", self.always_on_top_check.isChecked()) self.config.set("volume", self.volume_slider.value()) self.config.set("opacity", self.opacity_slider.value()) + self.config.set("pet_size", self.size_slider.value()) self.config.set("stretch_interval", self.stretch_spin.value()) self.config.set("pomodoro_work", self.pomodoro_work_spin.value()) self.config.set("pomodoro_break", self.pomodoro_break_spin.value()) diff --git a/src/ui/tray_menu.py b/src/ui/tray_menu.py index b3d0392..0490efe 100644 --- a/src/ui/tray_menu.py +++ b/src/ui/tray_menu.py @@ -30,6 +30,10 @@ def __init__(self, pet_window): self.window.timer_system.pomodoro_tick.connect(self.update_pomodoro_status) self.window.timer_system.pomodoro_finished.connect(self.on_pomodoro_finished) + # Подписка на сигнал смены режима лазерной указки + if hasattr(self.window, "input_manager") and self.window.input_manager: + self.window.input_manager.laser_mode_changed.connect(self.laser_action.setChecked) + def setup_menu(self): # 1. Секция статуса Pomodoro self.status_action = QAction("Таймер не запущен", self) @@ -59,10 +63,10 @@ def setup_menu(self): self.menu.addSeparator() # Лазерная указка - laser_action = QAction("Лазерная указка 🔴", self) - laser_action.setCheckable(True) - laser_action.triggered.connect(self.toggle_laser) - self.menu.addAction(laser_action) + self.laser_action = QAction("Лазерная указка 🔴", self) + self.laser_action.setCheckable(True) + self.laser_action.triggered.connect(self.toggle_laser) + self.menu.addAction(self.laser_action) self.menu.addSeparator() @@ -183,9 +187,10 @@ def start_break_timer(self, checked=False): def show_settings(self, checked=False): dialog = SettingsDialog(self.window.config, self.window) if dialog.exec(): - # Обновляем скин и прозрачность в реальном времени + # Обновляем скин, прозрачность и размер в реальном времени self.window.animation_manager.set_skin(self.window.config.get("skin")) self.window.set_opacity(self.window.config.get("opacity")) + self.window.set_pet_size(self.window.config.get("pet_size")) # Обновляем меню скинов self.update_skin_menu() # Обновляем режим "Поверх всех окон" в реальном времени diff --git a/src/utils/config_manager.py b/src/utils/config_manager.py index e6ad878..fb39511 100644 --- a/src/utils/config_manager.py +++ b/src/utils/config_manager.py @@ -12,7 +12,8 @@ class ConfigManager: "volume": 70, "language": "ru", "skin": "default", - "opacity": 100 + "opacity": 100, + "pet_size": 100 } def __init__(self, config_path="settings.json"): diff --git a/tests/test_utils.py b/tests/test_utils.py index 1a72e44..5342b3e 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -58,6 +58,8 @@ def test_always_on_top_logic(self): self.assertFalse(bool(flags & Qt.WindowStaysOnTopHint)) self.assertFalse(config.get("always_on_top")) + window.close() + def test_config_manager(self): config = ConfigManager(self.config_path) config.set("username", "TestUser") @@ -356,6 +358,7 @@ def test_delete_custom_skin(self): self.assertNotIn("custom_todel_skin", CAT_SKINS) self.assertEqual(config.get("skin"), "default") self.assertNotIn("custom_todel_skin", config.get("custom_skins") or {}) + dialog.close() finally: # Восстанавливаем моки гарантированно QMessageBox.question = original_question @@ -481,6 +484,85 @@ def test_input_manager_reset_logic(self): db.close() + def test_procedural_animations_playing_and_shaking(self): + os.environ["QT_QPA_PLATFORM"] = "offscreen" + from PySide6.QtWidgets import QApplication, QLabel + from src.core.animation_manager import AnimationManager + from src.utils.paths import ANIMATIONS_DIR + + app = QApplication.instance() or QApplication([]) + label = QLabel() + config = ConfigManager(self.config_path) + am = AnimationManager(label, config) + + # Подкладываем минимальный SVG-рендерер для вызова update_frame + test_svg_path = os.path.join(ANIMATIONS_DIR, "svg_skins", "cat_default.svg") + if not os.path.exists(test_svg_path): + os.makedirs(os.path.dirname(test_svg_path), exist_ok=True) + with open(test_svg_path, "w", encoding="utf-8") as f: + f.write("") + + am.set_animation(test_svg_path) + + # Вызываем update_frame в состояниях playing и shaking + am.play_state("playing") + am.update_frame() + self.assertIsNotNone(label.pixmap()) + + am.play_state("shaking") + am.update_frame() + self.assertIsNotNone(label.pixmap()) + + am.anim_timer.stop() + + def test_laser_mode_changed_signal(self): + mock_window = MagicMock() + mock_cursor = MagicMock() + mock_cursor.pos.return_value = QPoint(100, 100) + mock_window.cursor.return_value = mock_cursor + + db = DataStore(self.db_path) + im = InputManager(mock_window, db) + + signal_mock = MagicMock() + im.laser_mode_changed.connect(signal_mock) + + im.toggle_laser_mode() + signal_mock.assert_called_with(True) + + im.toggle_laser_mode() + signal_mock.assert_called_with(False) + + db.close() + + def test_pet_size_configuration(self): + os.environ["QT_QPA_PLATFORM"] = "offscreen" + from PySide6.QtWidgets import QApplication + from src.ui.settings_dialog import SettingsDialog + + app = QApplication.instance() or QApplication([]) + + config = ConfigManager(self.config_path) + self.assertEqual(config.get("pet_size"), 100) + + # Тест PetWindow + window = PetWindow(config) + self.assertEqual(window.original_size.width(), 100) + + window.set_pet_size(150) + self.assertEqual(window.original_size.width(), 150) + self.assertEqual(config.get("pet_size"), 150) + + # Тест SettingsDialog + dialog = SettingsDialog(config) + self.assertEqual(dialog.size_slider.value(), 150) + dialog.size_slider.setValue(200) + dialog.save_settings() + self.assertEqual(config.get("pet_size"), 200) + + window.close() + dialog.close() + def test_stats_dialog_reset_ui_flow(self): from src.ui.stats_dialog import StatsDialog from PySide6.QtWidgets import QMessageBox, QApplication @@ -512,6 +594,12 @@ def test_stats_dialog_reset_ui_flow(self): self.assertEqual(db.get_affection_points(), 0) QMessageBox.information.assert_called_once() finally: + if 'dialog' in locals() and hasattr(dialog, 'update_timer'): + dialog.update_timer.stop() + if 'dialog' in locals(): + dialog.close() + dialog.deleteLater() + app.processEvents() QMessageBox.question = original_question QMessageBox.information = original_information db.close() diff --git a/verification/screenshots/stats_history.png b/verification/screenshots/stats_history.png index 1fa59b4..845b7d0 100644 Binary files a/verification/screenshots/stats_history.png and b/verification/screenshots/stats_history.png differ