From 2d831fbc2dc42135eb9805de069150e58e5b3a64 Mon Sep 17 00:00:00 2001 From: Ysm-04 Date: Thu, 28 May 2026 21:16:08 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E6=A1=8C=E5=AE=A0=E5=86=85?= =?UTF-8?q?=E6=A0=B8=E5=9F=BA=E7=A1=80=E8=AE=BE=E6=96=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 +- CMakeLists.txt | 15 +++ docs/implementation_plan.md | 104 +++++++++++++-- main.cpp | 23 +++- src/character/AnimationClip.cpp | 45 +++++++ src/character/AnimationClip.h | 26 ++++ src/character/FrameAnimator.cpp | 131 +++++++++++++++++++ src/character/FrameAnimator.h | 37 ++++++ src/config/AppConfig.h | 10 ++ src/config/ConfigManager.cpp | 130 +++++++++++++++++++ src/config/ConfigManager.h | 19 +++ src/state/PetStateMachine.cpp | 156 +++++++++++++++++++++++ src/state/PetStateMachine.h | 33 +++++ src/tray/TrayController.cpp | 128 +++++++++++++++++++ src/tray/TrayController.h | 26 ++++ src/ui/PetView.cpp | 20 +++ src/ui/PetView.h | 14 +++ src/ui/PetWindow.cpp | 217 ++++++++++++++++++++------------ src/ui/PetWindow.h | 32 +++-- src/util/Logger.cpp | 102 +++++++++++++++ src/util/Logger.h | 17 +++ 21 files changed, 1190 insertions(+), 100 deletions(-) create mode 100644 src/character/AnimationClip.cpp create mode 100644 src/character/AnimationClip.h create mode 100644 src/character/FrameAnimator.cpp create mode 100644 src/character/FrameAnimator.h create mode 100644 src/config/AppConfig.h create mode 100644 src/config/ConfigManager.cpp create mode 100644 src/config/ConfigManager.h create mode 100644 src/state/PetStateMachine.cpp create mode 100644 src/state/PetStateMachine.h create mode 100644 src/tray/TrayController.cpp create mode 100644 src/tray/TrayController.h create mode 100644 src/ui/PetView.cpp create mode 100644 src/ui/PetView.h create mode 100644 src/util/Logger.cpp create mode 100644 src/util/Logger.h diff --git a/.gitignore b/.gitignore index e3bc204..874ba6b 100644 --- a/.gitignore +++ b/.gitignore @@ -19,7 +19,6 @@ cmake-build-*/ *.lib # Runtime/config files generated during development -config/ -logs/ +/config/ +/logs/ *.broken.json - diff --git a/CMakeLists.txt b/CMakeLists.txt index ed500b4..76e8950 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -14,12 +14,27 @@ find_package(Qt6 REQUIRED COMPONENTS Widgets) qt_add_executable(QtDesktopPet main.cpp + src/character/AnimationClip.h + src/character/AnimationClip.cpp src/character/CharacterPackage.h src/character/CharacterPackage.cpp src/character/CharacterPackageLoader.h src/character/CharacterPackageLoader.cpp + src/character/FrameAnimator.h + src/character/FrameAnimator.cpp + src/config/AppConfig.h + src/config/ConfigManager.h + src/config/ConfigManager.cpp + src/state/PetStateMachine.h + src/state/PetStateMachine.cpp + src/tray/TrayController.h + src/tray/TrayController.cpp + src/ui/PetView.h + src/ui/PetView.cpp src/ui/PetWindow.h src/ui/PetWindow.cpp + src/util/Logger.h + src/util/Logger.cpp ) target_compile_definitions(QtDesktopPet diff --git a/docs/implementation_plan.md b/docs/implementation_plan.md index 7736f7b..590e773 100644 --- a/docs/implementation_plan.md +++ b/docs/implementation_plan.md @@ -102,13 +102,25 @@ shiroko/ └── error/ ``` -每个状态当前包含 4 帧 PNG。 +当前素材版本:`2.1.0-stable`。 + +当前帧数: + +```text +idle :30 帧 +talk :20 帧 +think :30 帧 +sleep :30 帧 +happy :20 帧 +drag :30 帧 +error :20 帧 +``` 需要注意: ```text -1. character.json 中 base.width/base.height 为 627x627,超过原开发文档建议的 512x512 -2. 原型阶段可以接受,但后续需要观察内存、缩放缓存和低配设备表现 +1. character.json 中 base.width/base.height 当前为 512x512 +2. 当前实现会预加载当前角色包的全部状态帧,后续需要观察内存和低配设备表现 3. 如果项目公开发布或推送远程仓库,需要确认 shiroko 素材版权和再分发权限 4. 版权不明确前,不应把它作为正式开源发布素材承诺 ``` @@ -504,16 +516,88 @@ shiroko/ --- -## 13. 当前未决问题 +## 13. 当前项目进度 + +截至当前工作区,项目已经完成以下内容: + +```text +1. 阶段 0 工程基础: + 已有 .gitignore、CMakeLists.txt、main.cpp、src/、docs/、resources/ 等基础结构 + +2. 阶段 1 最小桌宠窗口: + 已实现透明无边框窗口、拖动、右键退出、置顶切换 + +3. 阶段 2A 角色包最小读取: + 已有 CharacterPackage / CharacterPackageLoader + 能读取 shiroko/character.json 并收集状态帧路径 + +4. 阶段 2B idle 帧动画: + 已新增 AnimationClip / FrameAnimator + 当前实现会把当前角色包状态帧加载为 QPixmap 缓存,避免每帧读硬盘 + +5. 阶段 3 状态机初版: + 已新增 PetStateMachine + 已支持 idle / drag / think / talk / happy / sleep / error 的基础请求、拖动优先和缺失状态回退 idle + +6. 阶段 4 基础设施的一部分: + 已新增 PetView,拆分显示职责 + 已新增 TrayController,支持托盘显示、隐藏、退出 + 已新增 ConfigManager,保存窗口位置和置顶状态 + 已新增 Logger,支持文件日志和基础轮转 +``` + +当前实现与计划仍存在差异: + +```text +1. shiroko 角色包仍位于项目根目录 shiroko/,尚未移动到 resources/characters/shiroko +2. 当前未实现 AI 接入、AI 配置界面和 ChatBubble +3. 当前未实现 SettingsDialog +4. 当前 ConfigManager 只保存窗口位置和置顶状态,尚未保存缩放、性能模式、角色选择 +5. 当前 Logger 只接入启动、退出、配置和角色包加载失败等低频日志 +6. 当前 FrameAnimator 采用当前角色包全部状态帧预加载,尚未做懒加载 +7. 本轮架构调整后尚未执行构建、编译或运行验证 +``` + +--- + +## 14. 下一步建议 + +短期建议: + +```text +1. 在用户确认后做一次构建验证 +2. 修复构建或静态检查发现的问题 +3. 补最小 README.md,记录当前开发状态、构建方式、素材版权提示 +4. 确认 LICENSE 是否采用 MIT +5. 在进入 AI 前,做一次桌宠内核稳定性检查: + - 启动显示 + - idle 动画 + - 状态切换 + - 托盘隐藏 / 显示 + - 配置保存 / 恢复 + - 日志写入 / 轮转路径 +``` + +中期建议: + +```text +1. 补 SettingsDialog 的最小框架 +2. 补 ChatBubble +3. 再接入 OpenAI Compatible 非流式 AI 对话 +4. AI 接入后再做 think -> talk -> idle / error -> idle 状态联动 +``` + +--- + +## 15. 当前未决问题 后续开始写代码前,需要逐项确认: ```text -1. 是否创建 .gitignore -2. 是否创建 MIT LICENSE +1. 是否创建 MIT LICENSE +2. 是否创建项目根 README.md 3. 是否把 shiroko 移动到 resources/characters/shiroko -4. 是否使用 shiroko/preview.png 作为阶段 1 单图 -5. 是否立即创建最小 Qt Widgets 工程 -6. 是否将 build/、.vs/、CMake 生成目录全部加入 .gitignore -7. shiroko 素材是否允许提交到远程仓库 +4. 是否保持当前“预加载全部当前角色状态帧”的策略,还是改成按状态懒加载 +5. shiroko 素材是否允许作为正式开源发布素材继续保留在仓库中 +6. 是否在下一步执行构建验证 ``` diff --git a/main.cpp b/main.cpp index 0a693ef..182b63c 100644 --- a/main.cpp +++ b/main.cpp @@ -1,6 +1,11 @@ #include +#include +#include +#include "src/config/ConfigManager.h" +#include "src/tray/TrayController.h" #include "src/ui/PetWindow.h" +#include "src/util/Logger.h" int main(int argc, char *argv[]) { @@ -8,9 +13,25 @@ int main(int argc, char *argv[]) QApplication::setApplicationName("QtDesktopPet"); QApplication::setOrganizationName("QtDesktopPet"); + Logger::info(QStringLiteral("Application started.")); + + ConfigManager configManager; PetWindow window; + window.applyAppConfig(configManager.loadAppConfig()); + + TrayController trayController(&window); + trayController.show(); + + QObject::connect(&app, &QCoreApplication::aboutToQuit, [&configManager, &window]() { + if (!configManager.saveAppConfig(window.currentAppConfig())) + { + Logger::warning(QStringLiteral("Failed to save app config.")); + } + + Logger::info(QStringLiteral("Application is exiting.")); + }); + window.show(); return app.exec(); } - diff --git a/src/character/AnimationClip.cpp b/src/character/AnimationClip.cpp new file mode 100644 index 0000000..2abb32b --- /dev/null +++ b/src/character/AnimationClip.cpp @@ -0,0 +1,45 @@ +#include "AnimationClip.h" + +#include + +AnimationClip AnimationClip::fromState(const CharacterState &state, const QSize &targetSize) +{ + AnimationClip clip; + clip.stateName = state.name; + clip.fps = state.fps; + clip.loop = state.loop; + clip.nextState = state.nextState; + + for (const QString &framePath : state.framePaths) + { + QPixmap pixmap(framePath); + if (pixmap.isNull()) + { + continue; + } + + if (targetSize.isValid()) + { + pixmap = pixmap.scaled(targetSize, Qt::KeepAspectRatio, Qt::SmoothTransformation); + } + + clip.m_frames.append(pixmap); + } + + return clip; +} + +bool AnimationClip::isValid() const +{ + return fps > 0 && !m_frames.isEmpty(); +} + +const QPixmap &AnimationClip::frameAt(int index) const +{ + return m_frames.at(index); +} + +int AnimationClip::frameCount() const +{ + return m_frames.size(); +} diff --git a/src/character/AnimationClip.h b/src/character/AnimationClip.h new file mode 100644 index 0000000..ac874b9 --- /dev/null +++ b/src/character/AnimationClip.h @@ -0,0 +1,26 @@ +#pragma once + +#include "CharacterPackage.h" + +#include +#include +#include +#include + +class AnimationClip +{ +public: + static AnimationClip fromState(const CharacterState &state, const QSize &targetSize); + + bool isValid() const; + const QPixmap &frameAt(int index) const; + int frameCount() const; + + QString stateName; + int fps = 0; + bool loop = true; + QString nextState; + +private: + QVector m_frames; +}; diff --git a/src/character/FrameAnimator.cpp b/src/character/FrameAnimator.cpp new file mode 100644 index 0000000..eb68f4f --- /dev/null +++ b/src/character/FrameAnimator.cpp @@ -0,0 +1,131 @@ +#include "FrameAnimator.h" + +#include + +#include + +FrameAnimator::FrameAnimator() +{ + QObject::connect(&m_timer, &QTimer::timeout, [this]() { + advanceFrame(); + }); +} + +void FrameAnimator::setFrameChangedCallback(FrameChangedCallback callback) +{ + m_frameChangedCallback = std::move(callback); +} + +void FrameAnimator::setClipFinishedCallback(ClipFinishedCallback callback) +{ + m_clipFinishedCallback = std::move(callback); +} + +void FrameAnimator::play(const AnimationClip *clip) +{ + if (clip == nullptr || !clip->isValid()) + { + stop(); + return; + } + + m_timer.stop(); + m_clip = clip; + m_frameIndex = 0; + m_paused = false; + + if (m_frameChangedCallback) + { + m_frameChangedCallback(m_clip->frameAt(m_frameIndex)); + } + + const int intervalMs = qMax(1, 1000 / m_clip->fps); + m_timer.start(intervalMs); +} + +void FrameAnimator::pause() +{ + if (m_clip == nullptr) + { + return; + } + + m_timer.stop(); + m_paused = true; +} + +void FrameAnimator::resume() +{ + if (m_clip == nullptr || !m_clip->isValid() || !m_paused) + { + return; + } + + const int intervalMs = qMax(1, 1000 / m_clip->fps); + m_timer.start(intervalMs); + m_paused = false; +} + +void FrameAnimator::stop() +{ + m_timer.stop(); + m_clip = nullptr; + m_frameIndex = 0; + m_paused = false; +} + +bool FrameAnimator::isPlaying() const +{ + return m_timer.isActive() && m_clip != nullptr; +} + +bool FrameAnimator::isPaused() const +{ + return m_paused && m_clip != nullptr; +} + +QString FrameAnimator::currentStateName() const +{ + if (m_clip == nullptr) + { + return {}; + } + + return m_clip->stateName; +} + +void FrameAnimator::advanceFrame() +{ + if (m_clip == nullptr || !m_clip->isValid()) + { + stop(); + return; + } + + if (m_frameIndex + 1 >= m_clip->frameCount()) + { + if (m_clip->loop) + { + m_frameIndex = 0; + } + else + { + const QString nextState = m_clip->nextState; + stop(); + if (m_clipFinishedCallback) + { + m_clipFinishedCallback(nextState); + } + return; + } + } + else + { + ++m_frameIndex; + } + + if (m_frameChangedCallback) + { + m_frameChangedCallback(m_clip->frameAt(m_frameIndex)); + } +} diff --git a/src/character/FrameAnimator.h b/src/character/FrameAnimator.h new file mode 100644 index 0000000..4caeba9 --- /dev/null +++ b/src/character/FrameAnimator.h @@ -0,0 +1,37 @@ +#pragma once + +#include "AnimationClip.h" + +#include + +#include + +class FrameAnimator +{ +public: + using FrameChangedCallback = std::function; + using ClipFinishedCallback = std::function; + + FrameAnimator(); + + void setFrameChangedCallback(FrameChangedCallback callback); + void setClipFinishedCallback(ClipFinishedCallback callback); + + void play(const AnimationClip *clip); + void pause(); + void resume(); + void stop(); + bool isPlaying() const; + bool isPaused() const; + QString currentStateName() const; + +private: + void advanceFrame(); + + QTimer m_timer; + const AnimationClip *m_clip = nullptr; + int m_frameIndex = 0; + bool m_paused = false; + FrameChangedCallback m_frameChangedCallback; + ClipFinishedCallback m_clipFinishedCallback; +}; diff --git a/src/config/AppConfig.h b/src/config/AppConfig.h new file mode 100644 index 0000000..422104b --- /dev/null +++ b/src/config/AppConfig.h @@ -0,0 +1,10 @@ +#pragma once + +#include + +struct AppConfig +{ + QPoint windowPosition = QPoint(100, 100); + bool hasWindowPosition = false; + bool alwaysOnTop = true; +}; diff --git a/src/config/ConfigManager.cpp b/src/config/ConfigManager.cpp new file mode 100644 index 0000000..3e1de54 --- /dev/null +++ b/src/config/ConfigManager.cpp @@ -0,0 +1,130 @@ +#include "ConfigManager.h" + +#include "../util/Logger.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +const QString AppConfigFileName = QStringLiteral("app_config.json"); + +QJsonObject windowObjectFromConfig(const AppConfig &config) +{ + QJsonObject window; + window.insert(QStringLiteral("x"), config.windowPosition.x()); + window.insert(QStringLiteral("y"), config.windowPosition.y()); + window.insert(QStringLiteral("alwaysOnTop"), config.alwaysOnTop); + return window; +} +} + +ConfigManager::ConfigManager() = default; + +AppConfig ConfigManager::loadAppConfig() const +{ + AppConfig config; + + QFile file(appConfigPath()); + if (!file.exists()) + { + return config; + } + + if (!file.open(QIODevice::ReadOnly)) + { + Logger::warning(QStringLiteral("Unable to read app config.")); + return config; + } + + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) + { + file.close(); + backupBrokenConfig(appConfigPath()); + Logger::warning(QStringLiteral("App config is broken; default config will be used.")); + return config; + } + + const QJsonObject root = document.object(); + const QJsonObject window = root.value(QStringLiteral("window")).toObject(); + if (window.contains(QStringLiteral("x")) && window.contains(QStringLiteral("y"))) + { + config.windowPosition = QPoint( + window.value(QStringLiteral("x")).toInt(config.windowPosition.x()), + window.value(QStringLiteral("y")).toInt(config.windowPosition.y())); + config.hasWindowPosition = true; + } + + if (window.contains(QStringLiteral("alwaysOnTop"))) + { + config.alwaysOnTop = window.value(QStringLiteral("alwaysOnTop")).toBool(config.alwaysOnTop); + } + + return config; +} + +bool ConfigManager::saveAppConfig(const AppConfig &config) const +{ + const QString directoryPath = configDirectoryPath(); + QDir directory(directoryPath); + if (!directory.exists() && !directory.mkpath(QStringLiteral("."))) + { + Logger::warning(QStringLiteral("Unable to create config directory.")); + return false; + } + + QJsonObject root; + root.insert(QStringLiteral("window"), windowObjectFromConfig(config)); + + QFile file(appConfigPath()); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) + { + Logger::warning(QStringLiteral("Unable to open app config for writing.")); + return false; + } + + const QJsonDocument document(root); + return file.write(document.toJson(QJsonDocument::Indented)) >= 0; +} + +QString ConfigManager::appConfigPath() const +{ + return QDir(configDirectoryPath()).filePath(AppConfigFileName); +} + +QString ConfigManager::configDirectoryPath() const +{ + const QString path = QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation); + if (!path.isEmpty()) + { + return path; + } + + return QDir::currentPath(); +} + +void ConfigManager::backupBrokenConfig(const QString &filePath) const +{ + QFile file(filePath); + if (!file.exists()) + { + return; + } + + const QFileInfo fileInfo(filePath); + const QString backupPath = fileInfo.dir().filePath(fileInfo.completeBaseName() + QStringLiteral(".broken.json")); + if (QFile::exists(backupPath)) + { + QFile::remove(backupPath); + } + + file.rename(backupPath); + Logger::warning(QStringLiteral("Broken app config was backed up.")); +} diff --git a/src/config/ConfigManager.h b/src/config/ConfigManager.h new file mode 100644 index 0000000..1dea700 --- /dev/null +++ b/src/config/ConfigManager.h @@ -0,0 +1,19 @@ +#pragma once + +#include "AppConfig.h" + +#include + +class ConfigManager +{ +public: + ConfigManager(); + + AppConfig loadAppConfig() const; + bool saveAppConfig(const AppConfig &config) const; + QString appConfigPath() const; + +private: + QString configDirectoryPath() const; + void backupBrokenConfig(const QString &filePath) const; +}; diff --git a/src/state/PetStateMachine.cpp b/src/state/PetStateMachine.cpp new file mode 100644 index 0000000..0bb9d8b --- /dev/null +++ b/src/state/PetStateMachine.cpp @@ -0,0 +1,156 @@ +#include "PetStateMachine.h" + +namespace +{ +const QString IdleState = QStringLiteral("idle"); +const QString DragState = QStringLiteral("drag"); +const QString ErrorState = QStringLiteral("error"); +const QString ThinkState = QStringLiteral("think"); +const QString TalkState = QStringLiteral("talk"); +const QString HappyState = QStringLiteral("happy"); +const QString SleepState = QStringLiteral("sleep"); +} + +void PetStateMachine::setAvailableStates(const QSet &states) +{ + m_availableStates = states; + + if (!m_currentState.isEmpty()) + { + m_currentState = resolveState(m_currentState); + } +} + +QString PetStateMachine::currentState() const +{ + return m_currentState; +} + +QString PetStateMachine::start() +{ + m_dragging = false; + m_currentState = resolveState(IdleState); + return m_currentState; +} + +QString PetStateMachine::requestState(const QString &stateName, StateRequestSource source) +{ + if (m_dragging) + { + m_currentState = resolveState(DragState); + return m_currentState; + } + + const QString requestedState = resolveState(stateName); + if (shouldKeepCurrentState(requestedState, source)) + { + return m_currentState; + } + + m_currentState = requestedState; + return m_currentState; +} + +QString PetStateMachine::beginDrag() +{ + m_dragging = true; + m_currentState = resolveState(DragState); + return m_currentState; +} + +QString PetStateMachine::endDrag() +{ + m_dragging = false; + m_currentState = resolveState(IdleState); + return m_currentState; +} + +QString PetStateMachine::finishState(const QString &nextState) +{ + if (m_dragging) + { + m_currentState = resolveState(DragState); + return m_currentState; + } + + if (!nextState.isEmpty()) + { + m_currentState = resolveState(nextState); + } + else + { + m_currentState = resolveState(IdleState); + } + + return m_currentState; +} + +QString PetStateMachine::resolveState(const QString &stateName) const +{ + if (m_availableStates.contains(stateName)) + { + return stateName; + } + + if (m_availableStates.contains(IdleState)) + { + return IdleState; + } + + return {}; +} + +int PetStateMachine::priorityOf(const QString &stateName) const +{ + if (stateName == DragState) + { + return 60; + } + + if (stateName == ErrorState) + { + return 50; + } + + if (stateName == ThinkState) + { + return 40; + } + + if (stateName == TalkState) + { + return 30; + } + + if (stateName == HappyState) + { + return 20; + } + + if (stateName == SleepState) + { + return 10; + } + + return 0; +} + +bool PetStateMachine::shouldKeepCurrentState(const QString &requestedState, StateRequestSource source) const +{ + if (requestedState.isEmpty() || m_currentState.isEmpty()) + { + return false; + } + + if (source == StateRequestSource::Manual || source == StateRequestSource::System) + { + return false; + } + + if (m_currentState == IdleState) + { + return false; + } + + return priorityOf(m_currentState) > priorityOf(requestedState); +} diff --git a/src/state/PetStateMachine.h b/src/state/PetStateMachine.h new file mode 100644 index 0000000..b5a4f3a --- /dev/null +++ b/src/state/PetStateMachine.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include + +enum class StateRequestSource +{ + Manual, + Automatic, + System, +}; + +class PetStateMachine +{ +public: + void setAvailableStates(const QSet &states); + + QString currentState() const; + QString start(); + QString requestState(const QString &stateName, StateRequestSource source = StateRequestSource::Manual); + QString beginDrag(); + QString endDrag(); + QString finishState(const QString &nextState); + +private: + QString resolveState(const QString &stateName) const; + int priorityOf(const QString &stateName) const; + bool shouldKeepCurrentState(const QString &requestedState, StateRequestSource source) const; + + QSet m_availableStates; + QString m_currentState; + bool m_dragging = false; +}; diff --git a/src/tray/TrayController.cpp b/src/tray/TrayController.cpp new file mode 100644 index 0000000..3466a47 --- /dev/null +++ b/src/tray/TrayController.cpp @@ -0,0 +1,128 @@ +#include "TrayController.h" + +#include "../ui/PetWindow.h" + +#include +#include +#include +#include + +namespace +{ +QString trayIconPath() +{ + return QStringLiteral(PET_SOURCE_DIR) + QStringLiteral("/shiroko/preview.png"); +} + +QIcon loadTrayIcon() +{ + const QPixmap pixmap(trayIconPath()); + if (!pixmap.isNull()) + { + return QIcon(pixmap); + } + + return QIcon(); +} +} + +TrayController::TrayController(PetWindow *petWindow) + : m_petWindow(petWindow) +{ + m_trayIcon.setIcon(loadTrayIcon()); + m_trayIcon.setToolTip(QStringLiteral("QtDesktopPet")); + + createMenu(); + m_trayIcon.setContextMenu(&m_menu); + + QObject::connect(&m_trayIcon, &QSystemTrayIcon::activated, [this](QSystemTrayIcon::ActivationReason reason) { + if (reason == QSystemTrayIcon::Trigger || reason == QSystemTrayIcon::DoubleClick) + { + togglePetWindowVisibility(); + } + }); +} + +bool TrayController::isAvailable() const +{ + return QSystemTrayIcon::isSystemTrayAvailable(); +} + +void TrayController::show() +{ + if (!isAvailable()) + { + return; + } + + m_trayIcon.show(); +} + +void TrayController::createMenu() +{ + QAction *showAction = m_menu.addAction(QStringLiteral("显示桌宠")); + QObject::connect(showAction, &QAction::triggered, [this]() { + showPetWindow(); + }); + + QAction *hideAction = m_menu.addAction(QStringLiteral("隐藏桌宠")); + QObject::connect(hideAction, &QAction::triggered, [this]() { + hidePetWindow(); + }); + + m_menu.addSeparator(); + + QAction *quitAction = m_menu.addAction(QStringLiteral("退出")); + QObject::connect(quitAction, &QAction::triggered, [this]() { + quitApplication(); + }); +} + +void TrayController::showPetWindow() +{ + if (m_petWindow == nullptr) + { + return; + } + + m_petWindow->show(); + m_petWindow->raise(); + m_petWindow->resumeAnimation(); +} + +void TrayController::hidePetWindow() +{ + if (m_petWindow == nullptr) + { + return; + } + + m_petWindow->pauseAnimation(); + m_petWindow->hide(); +} + +void TrayController::togglePetWindowVisibility() +{ + if (m_petWindow == nullptr) + { + return; + } + + if (m_petWindow->isVisible()) + { + hidePetWindow(); + return; + } + + showPetWindow(); +} + +void TrayController::quitApplication() +{ + if (m_petWindow != nullptr) + { + m_petWindow->pauseAnimation(); + } + + QApplication::quit(); +} diff --git a/src/tray/TrayController.h b/src/tray/TrayController.h new file mode 100644 index 0000000..9d0ca15 --- /dev/null +++ b/src/tray/TrayController.h @@ -0,0 +1,26 @@ +#pragma once + +#include +#include + +class PetWindow; + +class TrayController +{ +public: + explicit TrayController(PetWindow *petWindow); + + bool isAvailable() const; + void show(); + +private: + void createMenu(); + void showPetWindow(); + void hidePetWindow(); + void togglePetWindowVisibility(); + void quitApplication(); + + PetWindow *m_petWindow = nullptr; + QSystemTrayIcon m_trayIcon; + QMenu m_menu; +}; diff --git a/src/ui/PetView.cpp b/src/ui/PetView.cpp new file mode 100644 index 0000000..c2bb0f2 --- /dev/null +++ b/src/ui/PetView.cpp @@ -0,0 +1,20 @@ +#include "PetView.h" + +PetView::PetView(QWidget *parent) + : QLabel(parent) +{ + setAlignment(Qt::AlignCenter); + setAttribute(Qt::WA_TranslucentBackground); +} + +void PetView::setFrame(const QPixmap &pixmap) +{ + clear(); + setPixmap(pixmap); +} + +void PetView::showFallbackText(const QString &text) +{ + clear(); + setText(text); +} diff --git a/src/ui/PetView.h b/src/ui/PetView.h new file mode 100644 index 0000000..cd8a6f7 --- /dev/null +++ b/src/ui/PetView.h @@ -0,0 +1,14 @@ +#pragma once + +#include +#include +#include + +class PetView : public QLabel +{ +public: + explicit PetView(QWidget *parent = nullptr); + + void setFrame(const QPixmap &pixmap); + void showFallbackText(const QString &text); +}; diff --git a/src/ui/PetWindow.cpp b/src/ui/PetWindow.cpp index 1921c34..a7c671c 100644 --- a/src/ui/PetWindow.cpp +++ b/src/ui/PetWindow.cpp @@ -1,17 +1,20 @@ #include "PetWindow.h" #include "../character/CharacterPackageLoader.h" +#include "../util/Logger.h" +#include "PetView.h" #include #include #include -#include #include #include #include #include #include #include +#include +#include #include namespace @@ -29,24 +32,26 @@ QString previewImagePath() PetWindow::PetWindow(QWidget *parent) : QWidget(parent) - , m_imageLabel(new QLabel(this)) - , m_currentFrameIndex(0) + , m_petView(new PetView(this)) , m_dragging(false) , m_alwaysOnTop(true) + , m_centerNextFrame(false) { setAttribute(Qt::WA_TranslucentBackground); setWindowFlags(Qt::FramelessWindowHint | Qt::Tool | Qt::WindowStaysOnTopHint); setMouseTracking(true); - m_imageLabel->setAlignment(Qt::AlignCenter); - m_imageLabel->setAttribute(Qt::WA_TranslucentBackground); - auto *layout = new QVBoxLayout(this); layout->setContentsMargins(0, 0, 0, 0); - layout->addWidget(m_imageLabel); + layout->addWidget(m_petView); - connect(&m_animationTimer, &QTimer::timeout, this, [this]() { - advanceStateFrame(); + m_frameAnimator.setFrameChangedCallback([this](const QPixmap &pixmap) { + setDisplayPixmap(pixmap, m_centerNextFrame); + m_centerNextFrame = false; + }); + + m_frameAnimator.setClipFinishedCallback([this](const QString &nextState) { + playResolvedState(m_stateMachine.finishState(nextState), false); }); m_idleBehaviorTimer.setSingleShot(true); @@ -62,6 +67,49 @@ PetWindow::PetWindow(QWidget *parent) loadInitialImage(); } +void PetWindow::applyAppConfig(const AppConfig &config) +{ + setAlwaysOnTop(config.alwaysOnTop); + + if (config.hasWindowPosition && isPointVisibleOnScreen(config.windowPosition)) + { + move(config.windowPosition); + } +} + +AppConfig PetWindow::currentAppConfig() const +{ + AppConfig config; + config.windowPosition = pos(); + config.hasWindowPosition = true; + config.alwaysOnTop = m_alwaysOnTop; + return config; +} + +void PetWindow::pauseAnimation() +{ + m_returnToIdleAfterResume = m_behaviorReturnTimer.isActive(); + m_idleBehaviorTimer.stop(); + m_behaviorReturnTimer.stop(); + m_frameAnimator.pause(); +} + +void PetWindow::resumeAnimation() +{ + m_frameAnimator.resume(); + + if (m_stateMachine.currentState() == QStringLiteral("idle")) + { + scheduleIdleBehavior(); + } + else if (m_returnToIdleAfterResume) + { + m_behaviorReturnTimer.start(4000); + } + + m_returnToIdleAfterResume = false; +} + void PetWindow::contextMenuEvent(QContextMenuEvent *event) { QMenu menu(this); @@ -108,7 +156,7 @@ void PetWindow::mousePressEvent(QMouseEvent *event) { m_dragging = true; m_dragOffset = event->globalPosition().toPoint() - frameGeometry().topLeft(); - playState(QStringLiteral("drag"), false); + playResolvedState(m_stateMachine.beginDrag(), false); event->accept(); return; } @@ -121,7 +169,7 @@ void PetWindow::mouseReleaseEvent(QMouseEvent *event) if (event->button() == Qt::LeftButton) { m_dragging = false; - playState(QStringLiteral("idle"), false); + playResolvedState(m_stateMachine.endDrag(), false); event->accept(); return; } @@ -133,15 +181,43 @@ void PetWindow::loadInitialImage() { QString loadError; m_characterPackage = CharacterPackageLoader::load(characterPackagePath(), &loadError); - if (m_characterPackage.hasState(QStringLiteral("idle"))) + if (!loadError.isEmpty()) { - playState(QStringLiteral("idle"), true); + Logger::warning(QStringLiteral("Character package load failed: ") + loadError); + } + + buildAnimationClips(); + + if (m_clips.contains(QStringLiteral("idle"))) + { + playResolvedState(m_stateMachine.start(), true); return; } setDisplayImage(previewImagePath(), true); } +void PetWindow::buildAnimationClips() +{ + m_clips.clear(); + + QSet availableStates; + const QSize targetSize(320, 320); + for (auto iterator = m_characterPackage.states.constBegin(); iterator != m_characterPackage.states.constEnd(); ++iterator) + { + AnimationClip clip = AnimationClip::fromState(iterator.value(), targetSize); + if (!clip.isValid()) + { + continue; + } + + availableStates.insert(iterator.key()); + m_clips.insert(iterator.key(), clip); + } + + m_stateMachine.setAvailableStates(availableStates); +} + void PetWindow::addStateTestActions(QMenu *menu) { QMenu *stateMenu = menu->addMenu(QStringLiteral("State Test")); @@ -157,7 +233,7 @@ void PetWindow::addStateTestActions(QMenu *menu) for (const QString &stateName : stateNames) { - if (!m_characterPackage.hasState(stateName)) + if (!m_clips.contains(stateName)) { continue; } @@ -169,84 +245,48 @@ void PetWindow::addStateTestActions(QMenu *menu) stateMenu->setEnabled(!stateMenu->actions().isEmpty()); } -void PetWindow::playState(const QString &stateName, bool centerWindow, bool autoReturn) +void PetWindow::playState(const QString &stateName, bool centerWindow) { - if (m_currentStateName == stateName && !m_currentFrames.isEmpty()) + playResolvedState(m_stateMachine.requestState(stateName), centerWindow); +} + +void PetWindow::playResolvedState(const QString &stateName, bool centerWindow) +{ + if (stateName.isEmpty()) { return; } - const CharacterState *state = m_characterPackage.state(stateName); - if (state == nullptr || state->framePaths.isEmpty() || state->fps <= 0) + if (m_frameAnimator.currentStateName() == stateName && m_frameAnimator.isPlaying()) { return; } + auto clipIterator = m_clips.constFind(stateName); + if (clipIterator == m_clips.constEnd()) + { + return; + } + + const AnimationClip *clip = &clipIterator.value(); m_idleBehaviorTimer.stop(); m_behaviorReturnTimer.stop(); - m_animationTimer.stop(); - m_currentStateName = stateName; - m_currentFrames = state->framePaths; - m_currentFrameIndex = 0; - - setDisplayImage(m_currentFrames.at(m_currentFrameIndex), centerWindow); - - const int intervalMs = qMax(1, 1000 / state->fps); - m_animationTimer.start(intervalMs); + m_centerNextFrame = centerWindow; + m_frameAnimator.play(clip); if (stateName == QStringLiteral("idle")) { scheduleIdleBehavior(); } - else if (autoReturn && state->loop) + else if (clip->loop) { m_behaviorReturnTimer.start(4000); } } -void PetWindow::advanceStateFrame() -{ - if (m_currentFrames.isEmpty()) - { - m_animationTimer.stop(); - return; - } - - const CharacterState *state = m_characterPackage.state(m_currentStateName); - if (state == nullptr) - { - m_animationTimer.stop(); - return; - } - - if (m_currentFrameIndex + 1 >= m_currentFrames.size()) - { - if (state->loop) - { - m_currentFrameIndex = 0; - } - else if (!state->nextState.isEmpty()) - { - playState(state->nextState, false); - return; - } - else - { - m_animationTimer.stop(); - return; - } - } - else - { - ++m_currentFrameIndex; - } - - setDisplayImage(m_currentFrames.at(m_currentFrameIndex), false); -} - void PetWindow::scheduleIdleBehavior() { - if (!m_characterPackage.hasState(QStringLiteral("idle"))) + if (!m_clips.contains(QStringLiteral("idle"))) { return; } @@ -257,7 +297,7 @@ void PetWindow::scheduleIdleBehavior() void PetWindow::playIdleBehavior() { - if (m_dragging || m_currentStateName != QStringLiteral("idle")) + if (m_dragging || m_stateMachine.currentState() != QStringLiteral("idle")) { scheduleIdleBehavior(); return; @@ -272,7 +312,7 @@ void PetWindow::playIdleBehavior() for (const QString &stateName : preferredStates) { - if (m_characterPackage.hasState(stateName)) + if (m_clips.contains(stateName)) { candidateStates.append(stateName); } @@ -285,14 +325,14 @@ void PetWindow::playIdleBehavior() } const int stateIndex = QRandomGenerator::global()->bounded(candidateStates.size()); - playState(candidateStates.at(stateIndex), false, true); + playResolvedState(m_stateMachine.requestState(candidateStates.at(stateIndex), StateRequestSource::Automatic), false); } void PetWindow::returnToIdleFromBehavior() { if (!m_dragging) { - playState(QStringLiteral("idle"), false); + playResolvedState(m_stateMachine.finishState(QStringLiteral("idle")), false); } } @@ -301,15 +341,20 @@ void PetWindow::setDisplayImage(const QString &imagePath, bool centerWindow) QPixmap pixmap(imagePath); if (pixmap.isNull()) { - m_imageLabel->setText(QStringLiteral("QtDesktopPet")); + m_petView->showFallbackText(QStringLiteral("QtDesktopPet")); resize(240, 160); return; } const QSize targetSize(320, 320); const QPixmap scaled = pixmap.scaled(targetSize, Qt::KeepAspectRatio, Qt::SmoothTransformation); - m_imageLabel->setPixmap(scaled); - resize(scaled.size()); + setDisplayPixmap(scaled, centerWindow); +} + +void PetWindow::setDisplayPixmap(const QPixmap &pixmap, bool centerWindow) +{ + m_petView->setFrame(pixmap); + resize(pixmap.size()); if (centerWindow) { @@ -321,9 +366,24 @@ void PetWindow::setDisplayImage(const QString &imagePath, bool centerWindow) } } +bool PetWindow::isPointVisibleOnScreen(const QPoint &point) const +{ + const QList screens = QGuiApplication::screens(); + for (const QScreen *screen : screens) + { + if (screen != nullptr && screen->availableGeometry().contains(point)) + { + return true; + } + } + + return false; +} + void PetWindow::setAlwaysOnTop(bool enabled) { m_alwaysOnTop = enabled; + const bool wasVisible = isVisible(); Qt::WindowFlags flags = windowFlags(); if (enabled) @@ -336,5 +396,8 @@ void PetWindow::setAlwaysOnTop(bool enabled) } setWindowFlags(flags); - show(); + if (wasVisible) + { + show(); + } } diff --git a/src/ui/PetWindow.h b/src/ui/PetWindow.h index 9557301..e82a8c4 100644 --- a/src/ui/PetWindow.h +++ b/src/ui/PetWindow.h @@ -1,20 +1,30 @@ #pragma once +#include "../character/AnimationClip.h" #include "../character/CharacterPackage.h" +#include "../character/FrameAnimator.h" +#include "../config/AppConfig.h" +#include "../state/PetStateMachine.h" -#include +#include #include -#include #include #include class QMenu; +class QPixmap; +class PetView; class PetWindow : public QWidget { public: explicit PetWindow(QWidget *parent = nullptr); + void applyAppConfig(const AppConfig &config); + AppConfig currentAppConfig() const; + void pauseAnimation(); + void resumeAnimation(); + protected: void contextMenuEvent(QContextMenuEvent *event) override; void mouseMoveEvent(QMouseEvent *event) override; @@ -23,24 +33,28 @@ protected: private: void loadInitialImage(); + void buildAnimationClips(); void addStateTestActions(QMenu *menu); - void playState(const QString &stateName, bool centerWindow, bool autoReturn = false); - void advanceStateFrame(); + void playState(const QString &stateName, bool centerWindow); + void playResolvedState(const QString &stateName, bool centerWindow); void scheduleIdleBehavior(); void playIdleBehavior(); void returnToIdleFromBehavior(); void setDisplayImage(const QString &imagePath, bool centerWindow); + void setDisplayPixmap(const QPixmap &pixmap, bool centerWindow); + bool isPointVisibleOnScreen(const QPoint &point) const; void setAlwaysOnTop(bool enabled); - QLabel *m_imageLabel; - QTimer m_animationTimer; + PetView *m_petView; QTimer m_idleBehaviorTimer; QTimer m_behaviorReturnTimer; CharacterPackage m_characterPackage; - QString m_currentStateName; - QStringList m_currentFrames; + QMap m_clips; + FrameAnimator m_frameAnimator; + PetStateMachine m_stateMachine; QPoint m_dragOffset; - int m_currentFrameIndex; bool m_dragging; bool m_alwaysOnTop; + bool m_centerNextFrame; + bool m_returnToIdleAfterResume = false; }; diff --git a/src/util/Logger.cpp b/src/util/Logger.cpp new file mode 100644 index 0000000..4c6cbeb --- /dev/null +++ b/src/util/Logger.cpp @@ -0,0 +1,102 @@ +#include "Logger.h" + +#include +#include +#include +#include +#include + +namespace +{ +const QString LogFileName = QStringLiteral("QtDesktopPet.log"); +constexpr qint64 MaxLogFileSize = 2 * 1024 * 1024; +constexpr int MaxRotatedLogFiles = 3; +} + +void Logger::info(const QString &message) +{ + write(QStringLiteral("INFO"), message); +} + +void Logger::warning(const QString &message) +{ + write(QStringLiteral("WARN"), message); +} + +void Logger::error(const QString &message) +{ + write(QStringLiteral("ERROR"), message); +} + +void Logger::write(const QString &level, const QString &message) +{ + QDir directory(logDirectoryPath()); + if (!directory.exists() && !directory.mkpath(QStringLiteral("."))) + { + return; + } + + rotateIfNeeded(); + + QFile file(logFilePath()); + if (!file.open(QIODevice::WriteOnly | QIODevice::Append | QIODevice::Text)) + { + return; + } + + QTextStream stream(&file); + stream << QDateTime::currentDateTime().toString(Qt::ISODate) + << " [" << level << "] " + << message + << '\n'; +} + +QString Logger::logDirectoryPath() +{ + const QString configPath = QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation); + if (!configPath.isEmpty()) + { + return QDir(configPath).filePath(QStringLiteral("logs")); + } + + return QDir(QDir::currentPath()).filePath(QStringLiteral("logs")); +} + +QString Logger::logFilePath() +{ + return QDir(logDirectoryPath()).filePath(LogFileName); +} + +void Logger::rotateIfNeeded() +{ + const QString currentPath = logFilePath(); + QFile currentFile(currentPath); + if (!currentFile.exists() || currentFile.size() < MaxLogFileSize) + { + return; + } + + for (int index = MaxRotatedLogFiles; index >= 1; --index) + { + const QString oldPath = currentPath + QStringLiteral(".") + QString::number(index); + const QString nextPath = currentPath + QStringLiteral(".") + QString::number(index + 1); + + if (!QFile::exists(oldPath)) + { + continue; + } + + if (index == MaxRotatedLogFiles) + { + QFile::remove(oldPath); + } + else + { + QFile::remove(nextPath); + QFile::rename(oldPath, nextPath); + } + } + + QFile::remove(currentPath + QStringLiteral(".1")); + QFile::rename(currentPath, currentPath + QStringLiteral(".1")); +} diff --git a/src/util/Logger.h b/src/util/Logger.h new file mode 100644 index 0000000..85e5c8c --- /dev/null +++ b/src/util/Logger.h @@ -0,0 +1,17 @@ +#pragma once + +#include + +class Logger +{ +public: + static void info(const QString &message); + static void warning(const QString &message); + static void error(const QString &message); + +private: + static void write(const QString &level, const QString &message); + static QString logDirectoryPath(); + static QString logFilePath(); + static void rotateIfNeeded(); +};