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