完善桌宠内核基础设施

This commit is contained in:
2026-05-28 21:16:08 +08:00
parent 6ff904c2df
commit 2d831fbc2d
21 changed files with 1190 additions and 100 deletions
+2 -3
View File
@@ -19,7 +19,6 @@ cmake-build-*/
*.lib *.lib
# Runtime/config files generated during development # Runtime/config files generated during development
config/ /config/
logs/ /logs/
*.broken.json *.broken.json
+15
View File
@@ -14,12 +14,27 @@ find_package(Qt6 REQUIRED COMPONENTS Widgets)
qt_add_executable(QtDesktopPet qt_add_executable(QtDesktopPet
main.cpp main.cpp
src/character/AnimationClip.h
src/character/AnimationClip.cpp
src/character/CharacterPackage.h src/character/CharacterPackage.h
src/character/CharacterPackage.cpp src/character/CharacterPackage.cpp
src/character/CharacterPackageLoader.h src/character/CharacterPackageLoader.h
src/character/CharacterPackageLoader.cpp 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.h
src/ui/PetWindow.cpp src/ui/PetWindow.cpp
src/util/Logger.h
src/util/Logger.cpp
) )
target_compile_definitions(QtDesktopPet target_compile_definitions(QtDesktopPet
+94 -10
View File
@@ -102,13 +102,25 @@ shiroko/
└── error/ └── error/
``` ```
每个状态当前包含 4 帧 PNG 当前素材版本:`2.1.0-stable`
当前帧数:
```text
idle 30 帧
talk 20 帧
think 30 帧
sleep 30 帧
happy 20 帧
drag 30 帧
error 20 帧
```
需要注意: 需要注意:
```text ```text
1. character.json 中 base.width/base.height 为 627x627,超过原开发文档建议的 512x512 1. character.json 中 base.width/base.height 当前为 512x512
2. 原型阶段可以接受,但后续需要观察内存、缩放缓存和低配设备表现 2. 当前实现会预加载当前角色包的全部状态帧,后续需要观察内存和低配设备表现
3. 如果项目公开发布或推送远程仓库,需要确认 shiroko 素材版权和再分发权限 3. 如果项目公开发布或推送远程仓库,需要确认 shiroko 素材版权和再分发权限
4. 版权不明确前,不应把它作为正式开源发布素材承诺 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 ```text
1. 是否创建 .gitignore 1. 是否创建 MIT LICENSE
2. 是否创建 MIT LICENSE 2. 是否创建项目根 README.md
3. 是否把 shiroko 移动到 resources/characters/shiroko 3. 是否把 shiroko 移动到 resources/characters/shiroko
4. 是否使用 shiroko/preview.png 作为阶段 1 单图 4. 是否保持当前“预加载全部当前角色状态帧”的策略,还是改成按状态懒加载
5. 是否立即创建最小 Qt Widgets 工程 5. shiroko 素材是否允许作为正式开源发布素材继续保留在仓库中
6. 是否将 build/、.vs/、CMake 生成目录全部加入 .gitignore 6. 是否在下一步执行构建验证
7. shiroko 素材是否允许提交到远程仓库
``` ```
+22 -1
View File
@@ -1,6 +1,11 @@
#include <QApplication> #include <QApplication>
#include <QCoreApplication>
#include <QObject>
#include "src/config/ConfigManager.h"
#include "src/tray/TrayController.h"
#include "src/ui/PetWindow.h" #include "src/ui/PetWindow.h"
#include "src/util/Logger.h"
int main(int argc, char *argv[]) int main(int argc, char *argv[])
{ {
@@ -8,9 +13,25 @@ int main(int argc, char *argv[])
QApplication::setApplicationName("QtDesktopPet"); QApplication::setApplicationName("QtDesktopPet");
QApplication::setOrganizationName("QtDesktopPet"); QApplication::setOrganizationName("QtDesktopPet");
Logger::info(QStringLiteral("Application started."));
ConfigManager configManager;
PetWindow window; 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(); window.show();
return app.exec(); return app.exec();
} }
+45
View File
@@ -0,0 +1,45 @@
#include "AnimationClip.h"
#include <QSize>
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();
}
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include "CharacterPackage.h"
#include <QPixmap>
#include <QSize>
#include <QString>
#include <QVector>
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<QPixmap> m_frames;
};
+131
View File
@@ -0,0 +1,131 @@
#include "FrameAnimator.h"
#include <QtGlobal>
#include <utility>
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));
}
}
+37
View File
@@ -0,0 +1,37 @@
#pragma once
#include "AnimationClip.h"
#include <QTimer>
#include <functional>
class FrameAnimator
{
public:
using FrameChangedCallback = std::function<void(const QPixmap &)>;
using ClipFinishedCallback = std::function<void(const QString &)>;
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;
};
+10
View File
@@ -0,0 +1,10 @@
#pragma once
#include <QPoint>
struct AppConfig
{
QPoint windowPosition = QPoint(100, 100);
bool hasWindowPosition = false;
bool alwaysOnTop = true;
};
+130
View File
@@ -0,0 +1,130 @@
#include "ConfigManager.h"
#include "../util/Logger.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QStandardPaths>
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."));
}
+19
View File
@@ -0,0 +1,19 @@
#pragma once
#include "AppConfig.h"
#include <QString>
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;
};
+156
View File
@@ -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<QString> &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);
}
+33
View File
@@ -0,0 +1,33 @@
#pragma once
#include <QSet>
#include <QString>
enum class StateRequestSource
{
Manual,
Automatic,
System,
};
class PetStateMachine
{
public:
void setAvailableStates(const QSet<QString> &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<QString> m_availableStates;
QString m_currentState;
bool m_dragging = false;
};
+128
View File
@@ -0,0 +1,128 @@
#include "TrayController.h"
#include "../ui/PetWindow.h"
#include <QAction>
#include <QApplication>
#include <QIcon>
#include <QPixmap>
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();
}
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include <QMenu>
#include <QSystemTrayIcon>
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;
};
+20
View File
@@ -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);
}
+14
View File
@@ -0,0 +1,14 @@
#pragma once
#include <QLabel>
#include <QPixmap>
#include <QString>
class PetView : public QLabel
{
public:
explicit PetView(QWidget *parent = nullptr);
void setFrame(const QPixmap &pixmap);
void showFallbackText(const QString &text);
};
+139 -76
View File
@@ -1,17 +1,20 @@
#include "PetWindow.h" #include "PetWindow.h"
#include "../character/CharacterPackageLoader.h" #include "../character/CharacterPackageLoader.h"
#include "../util/Logger.h"
#include "PetView.h"
#include <QAction> #include <QAction>
#include <QContextMenuEvent> #include <QContextMenuEvent>
#include <QCursor> #include <QCursor>
#include <QFileInfo>
#include <QGuiApplication> #include <QGuiApplication>
#include <QMenu> #include <QMenu>
#include <QMouseEvent> #include <QMouseEvent>
#include <QPixmap> #include <QPixmap>
#include <QRandomGenerator> #include <QRandomGenerator>
#include <QScreen> #include <QScreen>
#include <QSet>
#include <QStringList>
#include <QVBoxLayout> #include <QVBoxLayout>
namespace namespace
@@ -29,24 +32,26 @@ QString previewImagePath()
PetWindow::PetWindow(QWidget *parent) PetWindow::PetWindow(QWidget *parent)
: QWidget(parent) : QWidget(parent)
, m_imageLabel(new QLabel(this)) , m_petView(new PetView(this))
, m_currentFrameIndex(0)
, m_dragging(false) , m_dragging(false)
, m_alwaysOnTop(true) , m_alwaysOnTop(true)
, m_centerNextFrame(false)
{ {
setAttribute(Qt::WA_TranslucentBackground); setAttribute(Qt::WA_TranslucentBackground);
setWindowFlags(Qt::FramelessWindowHint | Qt::Tool | Qt::WindowStaysOnTopHint); setWindowFlags(Qt::FramelessWindowHint | Qt::Tool | Qt::WindowStaysOnTopHint);
setMouseTracking(true); setMouseTracking(true);
m_imageLabel->setAlignment(Qt::AlignCenter);
m_imageLabel->setAttribute(Qt::WA_TranslucentBackground);
auto *layout = new QVBoxLayout(this); auto *layout = new QVBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0); layout->setContentsMargins(0, 0, 0, 0);
layout->addWidget(m_imageLabel); layout->addWidget(m_petView);
connect(&m_animationTimer, &QTimer::timeout, this, [this]() { m_frameAnimator.setFrameChangedCallback([this](const QPixmap &pixmap) {
advanceStateFrame(); setDisplayPixmap(pixmap, m_centerNextFrame);
m_centerNextFrame = false;
});
m_frameAnimator.setClipFinishedCallback([this](const QString &nextState) {
playResolvedState(m_stateMachine.finishState(nextState), false);
}); });
m_idleBehaviorTimer.setSingleShot(true); m_idleBehaviorTimer.setSingleShot(true);
@@ -62,6 +67,49 @@ PetWindow::PetWindow(QWidget *parent)
loadInitialImage(); 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) void PetWindow::contextMenuEvent(QContextMenuEvent *event)
{ {
QMenu menu(this); QMenu menu(this);
@@ -108,7 +156,7 @@ void PetWindow::mousePressEvent(QMouseEvent *event)
{ {
m_dragging = true; m_dragging = true;
m_dragOffset = event->globalPosition().toPoint() - frameGeometry().topLeft(); m_dragOffset = event->globalPosition().toPoint() - frameGeometry().topLeft();
playState(QStringLiteral("drag"), false); playResolvedState(m_stateMachine.beginDrag(), false);
event->accept(); event->accept();
return; return;
} }
@@ -121,7 +169,7 @@ void PetWindow::mouseReleaseEvent(QMouseEvent *event)
if (event->button() == Qt::LeftButton) if (event->button() == Qt::LeftButton)
{ {
m_dragging = false; m_dragging = false;
playState(QStringLiteral("idle"), false); playResolvedState(m_stateMachine.endDrag(), false);
event->accept(); event->accept();
return; return;
} }
@@ -133,15 +181,43 @@ void PetWindow::loadInitialImage()
{ {
QString loadError; QString loadError;
m_characterPackage = CharacterPackageLoader::load(characterPackagePath(), &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; return;
} }
setDisplayImage(previewImagePath(), true); setDisplayImage(previewImagePath(), true);
} }
void PetWindow::buildAnimationClips()
{
m_clips.clear();
QSet<QString> 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) void PetWindow::addStateTestActions(QMenu *menu)
{ {
QMenu *stateMenu = menu->addMenu(QStringLiteral("State Test")); QMenu *stateMenu = menu->addMenu(QStringLiteral("State Test"));
@@ -157,7 +233,7 @@ void PetWindow::addStateTestActions(QMenu *menu)
for (const QString &stateName : stateNames) for (const QString &stateName : stateNames)
{ {
if (!m_characterPackage.hasState(stateName)) if (!m_clips.contains(stateName))
{ {
continue; continue;
} }
@@ -169,84 +245,48 @@ void PetWindow::addStateTestActions(QMenu *menu)
stateMenu->setEnabled(!stateMenu->actions().isEmpty()); 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; return;
} }
const CharacterState *state = m_characterPackage.state(stateName); if (m_frameAnimator.currentStateName() == stateName && m_frameAnimator.isPlaying())
if (state == nullptr || state->framePaths.isEmpty() || state->fps <= 0)
{ {
return; return;
} }
auto clipIterator = m_clips.constFind(stateName);
if (clipIterator == m_clips.constEnd())
{
return;
}
const AnimationClip *clip = &clipIterator.value();
m_idleBehaviorTimer.stop(); m_idleBehaviorTimer.stop();
m_behaviorReturnTimer.stop(); m_behaviorReturnTimer.stop();
m_animationTimer.stop(); m_centerNextFrame = centerWindow;
m_currentStateName = stateName; m_frameAnimator.play(clip);
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);
if (stateName == QStringLiteral("idle")) if (stateName == QStringLiteral("idle"))
{ {
scheduleIdleBehavior(); scheduleIdleBehavior();
} }
else if (autoReturn && state->loop) else if (clip->loop)
{ {
m_behaviorReturnTimer.start(4000); 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() void PetWindow::scheduleIdleBehavior()
{ {
if (!m_characterPackage.hasState(QStringLiteral("idle"))) if (!m_clips.contains(QStringLiteral("idle")))
{ {
return; return;
} }
@@ -257,7 +297,7 @@ void PetWindow::scheduleIdleBehavior()
void PetWindow::playIdleBehavior() void PetWindow::playIdleBehavior()
{ {
if (m_dragging || m_currentStateName != QStringLiteral("idle")) if (m_dragging || m_stateMachine.currentState() != QStringLiteral("idle"))
{ {
scheduleIdleBehavior(); scheduleIdleBehavior();
return; return;
@@ -272,7 +312,7 @@ void PetWindow::playIdleBehavior()
for (const QString &stateName : preferredStates) for (const QString &stateName : preferredStates)
{ {
if (m_characterPackage.hasState(stateName)) if (m_clips.contains(stateName))
{ {
candidateStates.append(stateName); candidateStates.append(stateName);
} }
@@ -285,14 +325,14 @@ void PetWindow::playIdleBehavior()
} }
const int stateIndex = QRandomGenerator::global()->bounded(candidateStates.size()); 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() void PetWindow::returnToIdleFromBehavior()
{ {
if (!m_dragging) 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); QPixmap pixmap(imagePath);
if (pixmap.isNull()) if (pixmap.isNull())
{ {
m_imageLabel->setText(QStringLiteral("QtDesktopPet")); m_petView->showFallbackText(QStringLiteral("QtDesktopPet"));
resize(240, 160); resize(240, 160);
return; return;
} }
const QSize targetSize(320, 320); const QSize targetSize(320, 320);
const QPixmap scaled = pixmap.scaled(targetSize, Qt::KeepAspectRatio, Qt::SmoothTransformation); const QPixmap scaled = pixmap.scaled(targetSize, Qt::KeepAspectRatio, Qt::SmoothTransformation);
m_imageLabel->setPixmap(scaled); setDisplayPixmap(scaled, centerWindow);
resize(scaled.size()); }
void PetWindow::setDisplayPixmap(const QPixmap &pixmap, bool centerWindow)
{
m_petView->setFrame(pixmap);
resize(pixmap.size());
if (centerWindow) if (centerWindow)
{ {
@@ -321,9 +366,24 @@ void PetWindow::setDisplayImage(const QString &imagePath, bool centerWindow)
} }
} }
bool PetWindow::isPointVisibleOnScreen(const QPoint &point) const
{
const QList<QScreen *> screens = QGuiApplication::screens();
for (const QScreen *screen : screens)
{
if (screen != nullptr && screen->availableGeometry().contains(point))
{
return true;
}
}
return false;
}
void PetWindow::setAlwaysOnTop(bool enabled) void PetWindow::setAlwaysOnTop(bool enabled)
{ {
m_alwaysOnTop = enabled; m_alwaysOnTop = enabled;
const bool wasVisible = isVisible();
Qt::WindowFlags flags = windowFlags(); Qt::WindowFlags flags = windowFlags();
if (enabled) if (enabled)
@@ -336,5 +396,8 @@ void PetWindow::setAlwaysOnTop(bool enabled)
} }
setWindowFlags(flags); setWindowFlags(flags);
if (wasVisible)
{
show(); show();
} }
}
+23 -9
View File
@@ -1,20 +1,30 @@
#pragma once #pragma once
#include "../character/AnimationClip.h"
#include "../character/CharacterPackage.h" #include "../character/CharacterPackage.h"
#include "../character/FrameAnimator.h"
#include "../config/AppConfig.h"
#include "../state/PetStateMachine.h"
#include <QLabel> #include <QMap>
#include <QPoint> #include <QPoint>
#include <QStringList>
#include <QTimer> #include <QTimer>
#include <QWidget> #include <QWidget>
class QMenu; class QMenu;
class QPixmap;
class PetView;
class PetWindow : public QWidget class PetWindow : public QWidget
{ {
public: public:
explicit PetWindow(QWidget *parent = nullptr); explicit PetWindow(QWidget *parent = nullptr);
void applyAppConfig(const AppConfig &config);
AppConfig currentAppConfig() const;
void pauseAnimation();
void resumeAnimation();
protected: protected:
void contextMenuEvent(QContextMenuEvent *event) override; void contextMenuEvent(QContextMenuEvent *event) override;
void mouseMoveEvent(QMouseEvent *event) override; void mouseMoveEvent(QMouseEvent *event) override;
@@ -23,24 +33,28 @@ protected:
private: private:
void loadInitialImage(); void loadInitialImage();
void buildAnimationClips();
void addStateTestActions(QMenu *menu); void addStateTestActions(QMenu *menu);
void playState(const QString &stateName, bool centerWindow, bool autoReturn = false); void playState(const QString &stateName, bool centerWindow);
void advanceStateFrame(); void playResolvedState(const QString &stateName, bool centerWindow);
void scheduleIdleBehavior(); void scheduleIdleBehavior();
void playIdleBehavior(); void playIdleBehavior();
void returnToIdleFromBehavior(); void returnToIdleFromBehavior();
void setDisplayImage(const QString &imagePath, bool centerWindow); void setDisplayImage(const QString &imagePath, bool centerWindow);
void setDisplayPixmap(const QPixmap &pixmap, bool centerWindow);
bool isPointVisibleOnScreen(const QPoint &point) const;
void setAlwaysOnTop(bool enabled); void setAlwaysOnTop(bool enabled);
QLabel *m_imageLabel; PetView *m_petView;
QTimer m_animationTimer;
QTimer m_idleBehaviorTimer; QTimer m_idleBehaviorTimer;
QTimer m_behaviorReturnTimer; QTimer m_behaviorReturnTimer;
CharacterPackage m_characterPackage; CharacterPackage m_characterPackage;
QString m_currentStateName; QMap<QString, AnimationClip> m_clips;
QStringList m_currentFrames; FrameAnimator m_frameAnimator;
PetStateMachine m_stateMachine;
QPoint m_dragOffset; QPoint m_dragOffset;
int m_currentFrameIndex;
bool m_dragging; bool m_dragging;
bool m_alwaysOnTop; bool m_alwaysOnTop;
bool m_centerNextFrame;
bool m_returnToIdleAfterResume = false;
}; };
+102
View File
@@ -0,0 +1,102 @@
#include "Logger.h"
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QStandardPaths>
#include <QTextStream>
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"));
}
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#include <QString>
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();
};