diff --git a/src/core/animation_manager.py b/src/core/animation_manager.py index faa829b..8c9f4a5 100644 --- a/src/core/animation_manager.py +++ b/src/core/animation_manager.py @@ -132,7 +132,7 @@ def update_frame(self): painter.rotate(angle) elif self.current_state == "happy": # Прыжки - painter.translate(0, -abs(15 * math.sin(self.frame_counter * 0.5))) + painter.translate(0, -abs(size.height() * 0.15 * math.sin(self.frame_counter * 0.5))) elif self.current_state == "sleeping": # Глубокое медленное дыхание + наклон scale = 1.0 + 0.05 * math.sin(self.frame_counter * 0.1) @@ -152,7 +152,7 @@ def update_frame(self): elif self.current_state == "eating": # Наклоны головы вперед-назад при еде scale_y = 1.0 + 0.1 * abs(math.sin(self.frame_counter * 0.8)) - painter.translate(0, 10 * (scale_y - 1.0)) + painter.translate(0, (size.height() * 0.1) * (scale_y - 1.0)) painter.scale(1.0, scale_y) elif self.current_state == "thinking": # Наклон + покачивание diff --git a/src/core/input_manager.py b/src/core/input_manager.py index 9ca9d10..45031db 100644 --- a/src/core/input_manager.py +++ b/src/core/input_manager.py @@ -410,8 +410,22 @@ 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_width = 100 + if hasattr(self.window, 'original_size'): + orig_sz = self.window.original_size + w = orig_sz.width() if callable(getattr(orig_sz, 'width', None)) else getattr(orig_sz, 'width', 100) + if isinstance(w, (int, float)): + pet_width = w + elif hasattr(self.window, 'width'): + w = self.window.width() + if isinstance(w, (int, float)): + pet_width = w + + pet_radius = pet_width * 0.6 + threshold_sq = pet_radius * pet_radius + + if dist_sq_pet < threshold_sq: # Исключаем пассивный фарм (требуем активное поглаживание: активное движение мыши и кулдаун) if self.last_pet_time == 0: self.last_pet_mouse_pos = (x, y) diff --git a/src/ui/main_window.py b/src/ui/main_window.py index e619905..18d8ee6 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,16 @@ def set_opacity(self, value): """Устанавливает прозрачность окна (0-100)""" self.setWindowOpacity(value / 100.0) + def set_pet_size(self, size): + """Устанавливает базовый размер окна питомца (50-250px)""" + size = max(50, min(250, int(size))) + self.original_size = QSize(size, size) + if not self.is_dragging: + self.resize(self.original_size) + self.animation_manager.update_size(self.size()) + if self.config: + self.config.set("pet_size", 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..46d333a 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("Размер питомца:")) + 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(self.on_size_slider_changed) + layout.addWidget(self.size_slider) + # Интервал растяжки layout.addWidget(QLabel("Интервал растяжки (мин):")) self.stretch_spin = QSpinBox() @@ -103,6 +118,12 @@ def __init__(self, config, parent=None): btn_layout.addWidget(cancel_btn) layout.addLayout(btn_layout) + def on_size_slider_changed(self, value): + self.size_val_label.setText(f"{value} px") + parent = self.parent() + if parent and hasattr(parent, "set_pet_size"): + parent.set_pet_size(value) + def play_test_sound(self): # Если есть родительское окно с менеджером звуков, проигрываем звук через него parent = self.parent() @@ -168,6 +189,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..e04efd4 100644 --- a/src/ui/tray_menu.py +++ b/src/ui/tray_menu.py @@ -183,9 +183,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() # Обновляем режим "Поверх всех окон" в реальном времени @@ -198,10 +199,9 @@ def show_settings(self, checked=False): self.window.timer_system.restart_stretch_timer() self.window.show_message("Настройки сохранены! 💾") else: - # Даже если диалог был отклонен, скин мог быть импортирован или удален мгновенно. - # Поэтому мы в любом случае синхронизируем меню выбора окрасов в трее. + # Если диалог был отменен, восстанавливаем размер питомца и скин из конфига + self.window.set_pet_size(self.window.config.get("pet_size")) self.update_skin_menu() - # Также обновляем отображаемый скин (если удалили текущий активный, он мог сброситься на default) self.window.animation_manager.set_skin(self.window.config.get("skin")) def show_stats(self, checked=False): 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..b7333b2 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -481,6 +481,84 @@ def test_input_manager_reset_logic(self): db.close() + def test_pet_size_config_and_window_resize(self): + os.environ["QT_QPA_PLATFORM"] = "offscreen" + from PySide6.QtWidgets import QApplication + app = QApplication.instance() or QApplication([]) + + config = ConfigManager(self.config_path) + self.assertEqual(config.get("pet_size"), 100) + + config.set("pet_size", 150) + self.assertEqual(config.get("pet_size"), 150) + + window = PetWindow(config) + self.assertEqual(window.original_size.width(), 150) + self.assertEqual(window.original_size.height(), 150) + + # Динамическая смена размера + window.set_pet_size(180) + self.assertEqual(window.original_size.width(), 180) + self.assertEqual(config.get("pet_size"), 180) + + def test_petting_radius_scaled_by_pet_size(self): + mock_window = MagicMock() + mock_window.original_size.width.return_value = 150 + mock_window.width.return_value = 150 + mock_window.height.return_value = 150 + + # Позиция центра на (150, 150) + mock_pos = MagicMock() + mock_pos.x.return_value = 75 + mock_pos.y.return_value = 75 + mock_window.get_cached_pos.return_value = mock_pos + + mock_cursor = MagicMock() + mock_cursor.pos.return_value = QPoint(75, 75) + mock_window.cursor.return_value = mock_cursor + + mock_window.animation_manager.current_state = "idle" + + db = DataStore(self.db_path) + im = InputManager(mock_window, db) + + # Радиус для 150px составляет 150 * 0.6 = 90px (порог 8100) + # Мышь на 150, 150 (расстояние от центра 75,75 до 150,150 = sqrt(75^2 + 75^2) = sqrt(11250) > 8100 -> вне радиуса) + # Мышь на 120, 120 (расстояние 45, 45 -> 45^2 + 45^2 = 4050 < 8100 -> внутри радиуса) + + # 1. Первая точка поглаживания в (120, 120) + im.handle_mouse(120, 120) + self.assertEqual(im.pending_stats["petting_count"], 0) + + # 2. Движение > 30px внутри расширенного радиуса (150, 150) с кулдауном + im.last_pet_time -= 1.0 + im.handle_mouse(155, 155) # мазок от (120,120) до (155,155) это ~49px + self.assertEqual(im.pending_stats["petting_count"], 1) + + db.close() + + def test_settings_dialog_pet_size_slider(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) + mock_parent = MagicMock() + + dialog = SettingsDialog(config, None) + dialog.parent = lambda: mock_parent + self.assertEqual(dialog.size_slider.value(), 100) + + # Сдвигаем слайдер + dialog.size_slider.setValue(120) + mock_parent.set_pet_size.assert_called_with(120) + + # Сохранение настроек + dialog.save_settings() + self.assertEqual(config.get("pet_size"), 120) + def test_stats_dialog_reset_ui_flow(self): from src.ui.stats_dialog import StatsDialog from PySide6.QtWidgets import QMessageBox, QApplication diff --git a/verification/screenshots/stats_history.png b/verification/screenshots/stats_history.png index 1fa59b4..a3c9ea7 100644 Binary files a/verification/screenshots/stats_history.png and b/verification/screenshots/stats_history.png differ