From 35d5fc017770d461c416830c02f12cdc273d3282 Mon Sep 17 00:00:00 2001 From: mx57 <38256814+mx57@users.noreply.github.com> Date: Mon, 7 Sep 2026 05:20:05 +0000 Subject: [PATCH] feat: add pet size customization and sync laser mode signal --- .jules/bolt.md | 4 ++++ main.py | 4 ++++ src/core/animation_manager.py | 10 +++++---- src/core/input_manager.py | 13 +++++++++-- src/ui/main_window.py | 14 +++++++++++- src/ui/settings_dialog.py | 16 +++++++++++++ src/ui/tray_menu.py | 14 +++++++----- src/utils/config_manager.py | 3 ++- tests/test_utils.py | 42 +++++++++++++++++++++++++++++++++++ 9 files changed, 107 insertions(+), 13 deletions(-) diff --git a/.jules/bolt.md b/.jules/bolt.md index 4d4e971..441ae2f 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -60,3 +60,7 @@ if is_visible: ## 2026-07-14 - [Polymorphic Audio Asset Recovery with Local Fallbacks] **Инсайт:** Отсутствие редких звуковых ассетов (например, `happy.wav`), запускаемых при получении достижений или повышении уровня, приводит к беззвучному поведению приложения без явного вывода ошибок в лог. Использование механизма полиморфного резервного копирования на базовый ассет (такой как `meow.wav`) обеспечивает непрерывный игровой аудио-отклик без необходимости дублирования файлов. **Действие:** В `SoundManager.play_sound` реализовать проверку отсутствия файла и, при необходимости, рекурсивно вызывать воспроизведение дефолтного звука-заглушки. + +## 2026-07-15 - [Dynamic Scaling and Cross-Component State Signals] +**Инсайт:** Масштабирование визуального размера окна без адаптации логических порогов (например, радиуса обнаружения поглаживания или высоты процедурного прыжка) приводит к искажениям интерактивности при изменении настроек. Кроме того, переключение внутренних режимов (таких как `laser_mode`) без генерации Qt-сигналов создает рассинхронизацию галочек в системном меню трея (`TrayMenu`). +**Действие:** Динамически вычислять поглаживание через `max(width, height) * 0.6` и амплитуды анимаций через относительные размеры окна `size.height() * 0.15`, а состояние режима транслировать через экземпляр `Signal(bool)`. diff --git a/main.py b/main.py index 8f75b20..c27b432 100644 --- a/main.py +++ b/main.py @@ -42,6 +42,10 @@ def main(): tray = TrayMenu(window) + # Подписка на сигналы синхронизации UI + if window.input_manager and hasattr(tray, 'laser_action'): + window.input_manager.laser_mode_changed.connect(tray.laser_action.setChecked) + # Cleanup on close window.closed.connect(input_manager.flush_all) window.closed.connect(lambda: input_manager.monitor.stop()) diff --git a/src/core/animation_manager.py b/src/core/animation_manager.py index faa829b..ad8bc72 100644 --- a/src/core/animation_manager.py +++ b/src/core/animation_manager.py @@ -131,8 +131,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_height = size.height() * 0.15 + painter.translate(0, -abs(jump_height * math.sin(self.frame_counter * 0.5))) elif self.current_state == "sleeping": # Глубокое медленное дыхание + наклон scale = 1.0 + 0.05 * math.sin(self.frame_counter * 0.1) @@ -150,9 +151,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_shift = size.height() * 0.1 + painter.translate(0, eat_shift * (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..f708818 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,13 @@ 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_w = self.window.width() if self.window else 100 + pet_h = self.window.height() if self.window else 100 + pet_radius = max(pet_w, pet_h) * 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) @@ -470,6 +477,8 @@ 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..2c31565 100644 --- a/src/ui/main_window.py +++ b/src/ui/main_window.py @@ -55,7 +55,10 @@ 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 +94,15 @@ def set_opacity(self, value): """Устанавливает прозрачность окна (0-100)""" self.setWindowOpacity(value / 100.0) + def set_pet_size(self, size): + """Устанавливает базовый размер питомца и подгоняет размер окна""" + if self.config: + self.config.set("pet_size", size) + self.original_size = QSize(size, size) + 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 5339824..f1d9378 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(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..017a48f 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) + self.laser_action.triggered.connect(self.toggle_laser) + 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.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() # Обновляем режим "Поверх всех окон" в реальном времени 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..42a355f 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -481,6 +481,48 @@ def test_input_manager_reset_logic(self): db.close() + def test_pet_size_setting_and_window_resize(self): + os.environ["QT_QPA_PLATFORM"] = "offscreen" + from PySide6.QtWidgets import QApplication + from PySide6.QtCore import QSize + app = QApplication.instance() or QApplication([]) + + config = ConfigManager(self.config_path) + config.set("pet_size", 150) + + window = PetWindow(config) + self.assertEqual(window.original_size, QSize(150, 150)) + self.assertEqual(window.size(), QSize(150, 150)) + + # Динамическое изменение через set_pet_size + window.set_pet_size(200) + self.assertEqual(window.original_size, QSize(200, 200)) + self.assertEqual(window.size(), QSize(200, 200)) + self.assertEqual(config.get("pet_size"), 200) + + def test_laser_mode_changed_signal(self): + mock_window = MagicMock() + mock_window.animation_manager.current_state = "idle" + 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_received = [] + im.laser_mode_changed.connect(lambda state: signal_received.append(state)) + + # Переключение в ВКЛ + im.toggle_laser_mode() + self.assertEqual(signal_received, [True]) + + # Переключение в ВЫКЛ + im.toggle_laser_mode() + self.assertEqual(signal_received, [True, False]) + + db.close() + def test_stats_dialog_reset_ui_flow(self): from src.ui.stats_dialog import StatsDialog from PySide6.QtWidgets import QMessageBox, QApplication