diff --git a/src/core/animation_manager.py b/src/core/animation_manager.py index 26041f2..9d28a66 100644 --- a/src/core/animation_manager.py +++ b/src/core/animation_manager.py @@ -128,8 +128,9 @@ def update_frame(self): angle = 5 * math.sin(self.frame_counter * 0.8) painter.rotate(angle) elif self.current_state == "happy": - # Прыжки - painter.translate(0, -abs(15 * math.sin(self.frame_counter * 0.5))) + # Прыжки, пропорциональные высоте окна котика + jump_amplitude = size.height() * 0.15 + painter.translate(0, -abs(jump_amplitude * math.sin(self.frame_counter * 0.5))) elif self.current_state == "sleeping": # Глубокое медленное дыхание + наклон scale = 1.0 + 0.05 * math.sin(self.frame_counter * 0.1) @@ -147,9 +148,10 @@ def update_frame(self): # Растягивание painter.scale(0.8, 1.4) 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)) + eat_offset = size.height() * 0.1 + painter.translate(0, eat_offset * (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 92b689a..f2c0daa 100644 --- a/src/core/input_manager.py +++ b/src/core/input_manager.py @@ -410,8 +410,11 @@ 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: + # Оптимизация: динамическое пороговое расстояние (60% ширины окна) + pet_pet_threshold = max(30, int(self.window.width() * 0.6)) + pet_pet_threshold_sq = pet_pet_threshold * pet_pet_threshold + + if dist_sq_pet < pet_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..9f67a8e 100644 --- a/src/ui/main_window.py +++ b/src/ui/main_window.py @@ -55,7 +55,9 @@ 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_val = self.config.get("pet_size") if self.config else 100 + self.original_size = QSize(pet_size_val, pet_size_val) # Начальный размер self.resize(self.original_size) @@ -91,6 +93,15 @@ def set_opacity(self, value): """Устанавливает прозрачность окна (0-100)""" self.setWindowOpacity(value / 100.0) + def set_pet_size(self, size_val): + """Устанавливает базовый размер питомца и изменяет размер окна""" + if self.config: + self.config.set("pet_size", size_val) + self.original_size = QSize(size_val, size_val) + if not self.is_dragging: + 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 9c5b77e..ea11099 100644 --- a/src/ui/settings_dialog.py +++ b/src/ui/settings_dialog.py @@ -33,7 +33,7 @@ def __init__(self, config, parent=None): self.volume_slider = QSlider(Qt.Horizontal) self.volume_slider.setRange(0, 100) self.volume_slider.setValue(self.config.get("volume")) - self.volume_slider.valueChanged.connect(lambda v: self.volume_val_label.setText(f"{v}%")) + self.volume_slider.valueChanged.connect(self.on_volume_changed) layout.addWidget(self.volume_slider) # Прозрачность @@ -51,6 +51,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) + # Размер котика (pet_size) + size_header_layout = QHBoxLayout() + size_header_layout.addWidget(QLabel("Размер котика (px):")) + self.size_val_label = QLabel(f"{self.config.get('pet_size')}x{self.config.get('pet_size')}") + 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}x{v}")) + layout.addWidget(self.size_slider) + # Интервал растяжки layout.addWidget(QLabel("Интервал растяжки (мин):")) self.stretch_spin = QSpinBox() @@ -102,6 +117,14 @@ def __init__(self, config, parent=None): btn_layout.addWidget(cancel_btn) layout.addLayout(btn_layout) + def on_volume_changed(self, value): + self.volume_val_label.setText(f"{value}%") + # Мгновенная интерактивная обратная связь - тихий тестовый "meow" + if self.parent() and hasattr(self.parent(), "sound_manager") and self.parent().sound_manager: + # На время проигрывания временно обновляем громкость в конфигурации, чтобы звук соответствовал позиции слайдера + self.config.set("volume", value) + self.parent().sound_manager.play_sound("meow") + def on_skin_changed(self, index=0): current_skin_id = self.skin_combo.currentData() is_custom = bool(current_skin_id and current_skin_id.startswith("custom_")) @@ -161,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/stats_dialog.py b/src/ui/stats_dialog.py index d647ec4..77e69a6 100644 --- a/src/ui/stats_dialog.py +++ b/src/ui/stats_dialog.py @@ -4,6 +4,8 @@ from src.utils.bonding_utils import get_level_info, ACHIEVEMENTS from datetime import datetime, timezone +from PySide6.QtCore import QTimer + class StatsDialog(QDialog): def __init__(self, db, parent=None): super().__init__(parent) @@ -11,15 +13,18 @@ def __init__(self, db, parent=None): self.setWindowTitle("Статистика Котика") self.setFixedWidth(320) + # Список для обновления шкал прогресса заблокированных достижений на лету + self.locked_progress_bars = {} + main_layout = QVBoxLayout(self) self.tabs = QTabWidget() main_layout.addWidget(self.tabs) # Вкладка 1: Прогресс - progress_tab = QWidget() - self.setup_progress_tab(progress_tab) - self.tabs.addTab(progress_tab, "Прогресс") + self.progress_tab = QWidget() + self.setup_progress_tab(self.progress_tab) + self.tabs.addTab(self.progress_tab, "Прогресс") # Вкладка 2: История history_tab = QWidget() @@ -31,42 +36,48 @@ def __init__(self, db, parent=None): close_btn.clicked.connect(self.accept) main_layout.addWidget(close_btn) + # Таймер обновления данных в реальном времени + self.update_timer = QTimer(self) + self.update_timer.timeout.connect(self.update_stats_live) + self.update_timer.start(500) + def setup_progress_tab(self, widget): layout = QVBoxLayout(widget) points = self.db.get_affection_points() + if self.parent() and hasattr(self.parent(), 'input_manager') and self.parent().input_manager: + points += self.parent().input_manager.pending_points level, title, points_in_level, points_for_next_level = get_level_info(points) # Заголовок - title_label = QLabel(f"Уровень {level}: {title}") - title_label.setAlignment(Qt.AlignCenter) - title_label.setStyleSheet("font-size: 16px; font-weight: bold; margin-bottom: 5px;") - layout.addWidget(title_label) + self.title_label = QLabel(f"Уровень {level}: {title}") + self.title_label.setAlignment(Qt.AlignCenter) + self.title_label.setStyleSheet("font-size: 16px; font-weight: bold; margin-bottom: 5px;") + layout.addWidget(self.title_label) # Общие очки и KPS в одной строке stats_row = QHBoxLayout() - points_label = QLabel(f"Всего: {points} ❤️") - points_label.setStyleSheet("font-weight: bold; font-size: 12px;") + self.points_label = QLabel(f"Всего: {points} ❤️") + self.points_label.setStyleSheet("font-weight: bold; font-size: 12px;") max_kps = self.db.get_stat("max_kps") - kps_label = QLabel(f"Рекорд: {max_kps} кл/сек ⚡") - kps_label.setStyleSheet("color: #555; font-size: 11px;") + if self.parent() and hasattr(self.parent(), 'input_manager') and self.parent().input_manager: + max_kps = max(max_kps, self.parent().input_manager.max_kps) + self.kps_label = QLabel(f"Рекорд: {max_kps} кл/сек ⚡") + self.kps_label.setStyleSheet("color: #555; font-size: 11px;") - stats_row.addWidget(points_label) + stats_row.addWidget(self.points_label) stats_row.addStretch() - stats_row.addWidget(kps_label) + stats_row.addWidget(self.kps_label) layout.addLayout(stats_row) # Прогресс бар - if points_for_next_level > 0: - layout.addWidget(QLabel(f"До следующего уровня: {points_for_next_level - points_in_level}")) - progress = QProgressBar() - progress.setMaximum(points_for_next_level) - progress.setValue(points_in_level) - progress.setFormat("%v / %m") - layout.addWidget(progress) - else: - layout.addWidget(QLabel("Максимальный уровень достигнут! 🎉")) + self.next_level_desc_label = QLabel() + layout.addWidget(self.next_level_desc_label) + self.level_progress = QProgressBar() + layout.addWidget(self.level_progress) + + self._update_level_progress_visuals(points, points_in_level, points_for_next_level) layout.addSpacing(10) layout.addWidget(QLabel("Достижения:")) @@ -94,6 +105,7 @@ def setup_progress_tab(self, widget): # Расчет прогресса progress_text = "" + current_val = 0 if not is_unlocked and 'goal' in ach_info and 'stat' in ach_info: if self.parent() and hasattr(self.parent(), 'input_manager') and self.parent().input_manager: im = self.parent().input_manager @@ -141,6 +153,8 @@ def setup_progress_tab(self, widget): } """) ach_text_layout.addWidget(prog_bar) + # Сохраняем ссылку для обновления в реальном времени + self.locked_progress_bars[ach_id] = (prog_bar, info_label, ach_info) ach_item_layout.addLayout(ach_text_layout) ach_item_layout.addStretch() @@ -151,6 +165,71 @@ def setup_progress_tab(self, widget): scroll.setFixedHeight(200) layout.addWidget(scroll) + def _update_level_progress_visuals(self, points, points_in_level, points_for_next_level): + if points_for_next_level > 0: + self.next_level_desc_label.setText(f"До следующего уровня: {points_for_next_level - points_in_level}") + self.level_progress.setMaximum(points_for_next_level) + self.level_progress.setValue(points_in_level) + self.level_progress.setFormat("%v / %m") + self.level_progress.show() + else: + self.next_level_desc_label.setText("Максимальный уровень достигнут! 🎉") + self.level_progress.hide() + + def update_stats_live(self): + """Регулярно опрашивает менеджер ввода и БД для обновления прогресса на лету.""" + points = self.db.get_affection_points() + max_kps = self.db.get_stat("max_kps") + + if self.parent() and hasattr(self.parent(), 'input_manager') and self.parent().input_manager: + im = self.parent().input_manager + points += im.pending_points + max_kps = max(max_kps, im.max_kps) + + level, title, points_in_level, points_for_next_level = get_level_info(points) + + # 1. Обновляем заголовок уровня и общие очки/KPS + self.title_label.setText(f"Уровень {level}: {title}") + self.points_label.setText(f"Всего: {points} ❤️") + self.kps_label.setText(f"Рекорд: {max_kps} кл/сек ⚡") + + # 2. Обновляем уровень прогресса + self._update_level_progress_visuals(points, points_in_level, points_for_next_level) + + # 3. Обновляем шкалы прогресса для закрытых достижений + unlocked = self.db.get_unlocked_achievements() + for ach_id, (prog_bar, label, ach_info) in list(self.locked_progress_bars.items()): + if ach_id in unlocked: + # Если достижение было разблокировано прямо сейчас, мы могли бы перерисовать, + # но для простоты и безопасности просто убираем его из отслеживания обновления + del self.locked_progress_bars[ach_id] + continue + + current_val = 0 + if self.parent() and hasattr(self.parent(), 'input_manager') and self.parent().input_manager: + im = self.parent().input_manager + if ach_info['stat'] == 'bonding_points': + current_val = im.last_affection_points + im.pending_points + elif ach_info['stat'] == 'total_clicks': + current_val = im.total_clicks_cache + im.pending_stats.get('total_clicks', 0) + elif ach_info['stat'] == 'max_kps': + current_val = im.max_kps + else: + current_val = self.db.get_stat(ach_info['stat']) + im.pending_stats.get(ach_info['stat'], 0) + else: + current_val = self.db.get_stat(ach_info['stat']) + + if ach_info['stat'] == 'level': + current_val = level + + goal = ach_info['goal'] + prog_bar.setValue(min(current_val, goal)) + + progress_text = "" + if goal > 0: + progress_text = f" ({current_val}/{goal})" + label.setText(f"{ach_info['title']}{progress_text}
{ach_info['desc']}") + def setup_history_tab(self, widget): layout = QVBoxLayout(widget) diff --git a/src/ui/tray_menu.py b/src/ui/tray_menu.py index b3d0392..9b28de7 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() # Обновляем режим "Поверх всех окон" в реальном времени 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 1e1019e..b4c7d3e 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -312,6 +312,66 @@ def test_custom_skins_integration(self): if "custom_test_skin" in CAT_SKINS: del CAT_SKINS["custom_test_skin"] + def test_pet_size_configuration_and_scaling(self): + config = ConfigManager(self.config_path) + # 1. Проверяем дефолтное значение pet_size + self.assertEqual(config.get("pet_size"), 100) + + # 2. Изменение размера котика + config.set("pet_size", 150) + self.assertEqual(config.get("pet_size"), 150) + + os.environ["QT_QPA_PLATFORM"] = "offscreen" + from PySide6.QtWidgets import QApplication + app = QApplication.instance() or QApplication([]) + + window = PetWindow(config) + # 3. Проверяем, что исходный размер окна соответствует новому размеру питомца + self.assertEqual(window.original_size.width(), 150) + self.assertEqual(window.original_size.height(), 150) + + # 4. Проверяем динамический метод изменения размера + window.set_pet_size(200) + self.assertEqual(window.original_size.width(), 200) + self.assertEqual(window.original_size.height(), 200) + self.assertEqual(config.get("pet_size"), 200) + + def test_dynamic_petting_threshold(self): + mock_window = MagicMock() + # Задаем ширину окна 100 пикселей. Ожидаемый порог поглаживания 60 пикселей (60% от 100). + # Порог в квадрате должен быть 3600. + mock_window.width.return_value = 100 + mock_window.height.return_value = 100 + + mock_pos = MagicMock() + mock_pos.x.return_value = 100 + mock_pos.y.return_value = 100 + mock_window.get_cached_pos.return_value = mock_pos + + mock_cursor = MagicMock() + mock_cursor.pos.return_value = QPoint(100, 100) + mock_window.cursor.return_value = mock_cursor + + mock_window.animation_manager.current_state = "idle" + + db = DataStore(self.db_path) + im = InputManager(mock_window, db) + + # Проверим, что котик реагирует при движении мыши (координата x сдвигается от 100 к 150) + im.handle_mouse(150, 150) + self.assertEqual(im.pending_stats["petting_count"], 0) + + # Симулируем поглаживание: проходим кулдаун и расстояние > 30px + im.last_pet_time -= 1.0 + im.handle_mouse(155, 155) # движение на 7px, поглаживание не засчитано + self.assertEqual(im.pending_stats["petting_count"], 0) + + im.last_pet_time -= 1.0 + im.handle_mouse(190, 190) # движение на 49px (расстояние > 30px), поглаживание внутри круга 60px засчитано + self.assertEqual(im.pending_stats["petting_count"], 1) + + db.close() + def test_delete_custom_skin(self): # 1. Подготовка тестового окружения from src.utils.bonding_utils import CAT_SKINS