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