diff --git a/src/core/animation_manager.py b/src/core/animation_manager.py index faa829b..f043641 100644 --- a/src/core/animation_manager.py +++ b/src/core/animation_manager.py @@ -157,6 +157,21 @@ def update_frame(self): elif self.current_state == "thinking": # Наклон + покачивание painter.rotate(10 + 5 * math.sin(self.frame_counter * 0.2)) + elif self.current_state == "playing": + # Веселые попрыгивания с покачиванием + angle = 8 * math.sin(self.frame_counter * 0.6) + jump_y = -abs(10 * math.sin(self.frame_counter * 0.6)) + painter.translate(0, jump_y) + painter.rotate(angle) + elif self.current_state == "shaking": + # Сильное головокружительное встряхивание с изменением масштаба + angle = 12 * math.sin(self.frame_counter * 1.2) + shake_x = random.randint(-5, 5) + shake_y = random.randint(-3, 3) + scale = 1.0 + 0.05 * math.sin(self.frame_counter * 0.8) + painter.translate(shake_x, shake_y) + painter.rotate(angle) + painter.scale(scale, scale) painter.translate(-size.width() / 2, -size.height() / 2) diff --git a/src/core/input_manager.py b/src/core/input_manager.py index 9ca9d10..d396392 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 @@ -410,8 +412,9 @@ def handle_mouse(self, x, y): dy_pet = y - center_y dist_sq_pet = dx_pet * dx_pet + dy_pet * dy_pet - # Оптимизация: сравнение квадрата расстояния (порог 60px -> 3600) - if dist_sq_pet < 3600: + # Динамический радиус поглаживания в зависимости от размера окна питомца + pet_radius = max(30.0, min(self.window.width(), self.window.height()) * 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 +473,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..5d35730 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,13 @@ def set_opacity(self, value): """Устанавливает прозрачность окна (0-100)""" self.setWindowOpacity(value / 100.0) + def set_pet_size(self, size): + """Устанавливает базовый размер питомца в пикселях (50-250).""" + self.original_size = QSize(size, size) + self.resize(self.original_size) + if self.animation_manager: + 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..ab57c66 100644 --- a/src/ui/settings_dialog.py +++ b/src/ui/settings_dialog.py @@ -52,6 +52,21 @@ 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):")) + self.size_val_label = QLabel(f"{self.config.get('pet_size')}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(self.config.get("pet_size")) + 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 +183,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..4cbc744 100644 --- a/src/ui/tray_menu.py +++ b/src/ui/tray_menu.py @@ -59,10 +59,13 @@ 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) + if self.window.input_manager: + self.laser_action.setChecked(self.window.input_manager.laser_mode) + self.window.input_manager.laser_mode_changed.connect(self.laser_action.setChecked) + self.laser_action.triggered.connect(self.toggle_laser) + self.menu.addAction(self.laser_action) self.menu.addSeparator() @@ -183,9 +186,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() # Обновляем режим "Поверх всех окон" в реальном времени @@ -238,9 +242,18 @@ def toggle_laser(self, checked): else: self.show_message("Мини-игра", "Лазерная указка выключена.") + def select_skin(self, skin_id): + self.window.animation_manager.set_skin(skin_id) + if self.window.config: + self.window.config.set("skin", skin_id) + self.update_skin_menu() + def update_skin_menu(self): self.skin_menu.clear() + current_skin = self.window.config.get("skin") if self.window.config else "default" for skin_id, name in CAT_SKINS.items(): action = QAction(name, self) - action.triggered.connect(lambda checked=False, sid=skin_id: self.window.animation_manager.set_skin(sid)) + action.setCheckable(True) + action.setChecked(skin_id == current_skin) + action.triggered.connect(lambda checked=False, sid=skin_id: self.select_skin(sid)) self.skin_menu.addAction(action) 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..d83b095 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -516,5 +516,146 @@ def test_stats_dialog_reset_ui_flow(self): QMessageBox.information = original_information db.close() + def test_procedural_animations_playing_shaking(self): + os.environ["QT_QPA_PLATFORM"] = "offscreen" + from PySide6.QtWidgets import QApplication, QLabel + from src.core.animation_manager import AnimationManager + from PySide6.QtSvg import QSvgRenderer + + app = QApplication.instance() or QApplication([]) + label = QLabel() + label.resize(100, 100) + config = ConfigManager(self.config_path) + am = AnimationManager(label, config) + + # Мокаем svg_renderer + am.svg_renderer = MagicMock(spec=QSvgRenderer) + + # Проверяем кадр состояния "playing" + am.play_state("playing") + am.update_frame() + self.assertEqual(am.current_state, "playing") + self.assertIsNotNone(label.pixmap()) + + # Проверяем кадр состояния "shaking" + am.play_state("shaking") + am.update_frame() + self.assertEqual(am.current_state, "shaking") + self.assertIsNotNone(label.pixmap()) + + am.anim_timer.stop() + label.close() + + def test_laser_mode_signal_and_tray_sync(self): + os.environ["QT_QPA_PLATFORM"] = "offscreen" + from PySide6.QtWidgets import QApplication + from src.ui.tray_menu import TrayMenu + + app = QApplication.instance() or QApplication([]) + config = ConfigManager(self.config_path) + window = PetWindow(config) + db = DataStore(self.db_path) + im = InputManager(window, db) + window.input_manager = im + + tray = TrayMenu(window) + + # Проверяем, что сигнал переключает галочку в трей-меню + im.toggle_laser_mode() + self.assertTrue(im.laser_mode) + self.assertTrue(tray.laser_action.isChecked()) + + im.toggle_laser_mode() + self.assertFalse(im.laser_mode) + self.assertFalse(tray.laser_action.isChecked()) + + im.watchdog.stop() + window.close() + tray.deleteLater() + db.close() + + def test_tray_menu_skin_checkmarks(self): + os.environ["QT_QPA_PLATFORM"] = "offscreen" + from PySide6.QtWidgets import QApplication + from src.ui.tray_menu import TrayMenu + + app = QApplication.instance() or QApplication([]) + config = ConfigManager(self.config_path) + config.set("skin", "orange") + window = PetWindow(config) + + tray = TrayMenu(window) + + # Находим action для "orange" и "default" + orange_action = None + default_action = None + for action in tray.skin_menu.actions(): + if action.text() == "Рыжий": + orange_action = action + elif action.text() == "Стандартный": + default_action = action + + self.assertIsNotNone(orange_action) + self.assertIsNotNone(default_action) + self.assertTrue(orange_action.isChecked()) + self.assertFalse(default_action.isChecked()) + + # Выбираем другой скин + tray.select_skin("default") + self.assertEqual(config.get("skin"), "default") + + window.close() + tray.deleteLater() + + def test_pet_size_and_dynamic_petting_radius(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) + + # 1. Проверяем дефолтный pet_size в ConfigManager + self.assertEqual(config.get("pet_size"), 100) + + # 2. Инициализация и изменение размера через PetWindow.set_pet_size + window = PetWindow(config) + self.assertEqual(window.width(), 100) + self.assertEqual(window.height(), 100) + + window.set_pet_size(150) + self.assertEqual(window.width(), 150) + self.assertEqual(window.height(), 150) + self.assertEqual(window.original_size.width(), 150) + + # 3. Настройка pet_size в SettingsDialog + dialog = SettingsDialog(config) + dialog.size_slider.setValue(180) + dialog.save_settings() + self.assertEqual(config.get("pet_size"), 180) + + # 4. Проверка динамического радиуса поглаживания в InputManager + db = DataStore(self.db_path) + im = InputManager(window, db) + + # При окне 150x150, центр на (175, 175) для позиционирования окна на (100, 100) + # pet_radius = max(30, 150 * 0.6) = 90px + mock_pos = MagicMock() + mock_pos.x.return_value = 100 + mock_pos.y.return_value = 100 + window.get_cached_pos = MagicMock(return_value=mock_pos) + + # Поглаживание в пределах 90px от центра (175, 175) - например на (235, 235) + im.handle_mouse(175, 175) + im.last_pet_time -= 1.0 + im.last_mouse_time -= 1.0 # симулируем нормальную скорость движения мыши (не мгновенный скачок) + im.handle_mouse(235, 235) # Расстояние от 175 до 235 = 60px (< 90px радиус), вектор движения ~85px > 30px + self.assertEqual(im.pending_stats["petting_count"], 1) + + im.watchdog.stop() + window.close() + dialog.close() + db.close() + if __name__ == '__main__': unittest.main() diff --git a/verification/screenshots/stats_history.png b/verification/screenshots/stats_history.png index 1fa59b4..99e8d62 100644 Binary files a/verification/screenshots/stats_history.png and b/verification/screenshots/stats_history.png differ