From 028ed1b18dbb7bc5e2c5944d2a6b52a5f77b09b4 Mon Sep 17 00:00:00 2001 From: DingVero Date: Wed, 8 Apr 2026 09:56:25 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E9=A2=84=E8=A7=88=E9=A1=B5?= =?UTF-8?q?=E4=BB=8B=E7=BB=8D=E4=BF=A1=E6=81=AF=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/core/CMakeLists.txt | 1 + client/core/domain/EntityIntro.h | 29 +++ client/core/domain/Project.h | 9 +- .../core/persistence/EntityPayloadBinary.cpp | 55 +++- client/core/persistence/EntityPayloadBinary.h | 2 +- client/core/workspace/ProjectWorkspace.cpp | 72 +++++- client/core/workspace/ProjectWorkspace.h | 8 +- client/gui/CMakeLists.txt | 2 + client/gui/dialogs/EntityIntroPopup.cpp | 176 +++++++++++++ client/gui/dialogs/EntityIntroPopup.h | 37 +++ client/gui/editor/EditorCanvas.cpp | 240 +++++++++++++++++- client/gui/editor/EditorCanvas.h | 34 +++ client/gui/main_window/MainWindow.cpp | 182 ++++++++++++- client/gui/main_window/MainWindow.h | 7 + client/gui/params/ParamControls.cpp | 6 +- client/gui/props/EntityPropertySection.cpp | 193 ++++++++++++++ client/gui/props/EntityPropertySection.h | 29 +++ 17 files changed, 1059 insertions(+), 23 deletions(-) create mode 100644 client/core/domain/EntityIntro.h create mode 100644 client/gui/dialogs/EntityIntroPopup.cpp create mode 100644 client/gui/dialogs/EntityIntroPopup.h diff --git a/client/core/CMakeLists.txt b/client/core/CMakeLists.txt index 31b6e78..2f41dfd 100644 --- a/client/core/CMakeLists.txt +++ b/client/core/CMakeLists.txt @@ -12,6 +12,7 @@ set(CORE_SOURCES ) set(CORE_HEADERS + ${CORE_ROOT}/domain/EntityIntro.h ${CORE_ROOT}/domain/Project.h ${CORE_ROOT}/workspace/ProjectWorkspace.h ${CORE_ROOT}/persistence/PersistentBinaryObject.h diff --git a/client/core/domain/EntityIntro.h b/client/core/domain/EntityIntro.h new file mode 100644 index 0000000..9ad8735 --- /dev/null +++ b/client/core/domain/EntityIntro.h @@ -0,0 +1,29 @@ +#pragma once + +#include +#include + +namespace core { + +/// 实体在「预览展示」模式下点击后显示的介绍内容(可持久化到 .hfe) +struct EntityIntroContent { + QString title; + QString bodyText; + QVector imagePathsRelative; + /// 预留:相对项目根的视频路径,将来可用 QMediaPlayer 播放 + QString videoPathRelative; + + void clear() { + title.clear(); + bodyText.clear(); + imagePathsRelative.clear(); + videoPathRelative.clear(); + } + + bool hasDisplayableContent() const { + return !title.trimmed().isEmpty() || !bodyText.trimmed().isEmpty() || + !imagePathsRelative.isEmpty() || !videoPathRelative.trimmed().isEmpty(); + } +}; + +} // namespace core diff --git a/client/core/domain/Project.h b/client/core/domain/Project.h index 912b7e3..9a3ae1a 100644 --- a/client/core/domain/Project.h +++ b/client/core/domain/Project.h @@ -1,5 +1,7 @@ #pragma once +#include "domain/EntityIntro.h" + #include #include #include @@ -47,8 +49,11 @@ public: int depth = 0; // 0..255 QString imagePath; // 相对路径,例如 "assets/entities/entity-1.png" QPointF imageTopLeftWorld; // 贴图左上角 world 坐标 - // 人为整体缩放,与深度驱动的距离缩放相乘(画布中 visualScale = distanceScale * userScale) + // 人为整体缩放,与深度驱动的距离缩放相乘(画布中 visualScale = distanceScale * userScale; + // distanceScale 在有 distanceScaleCalibMult 时为 (0.5+depth01)/calib,使抠图处为 1.0) double userScale = 1.0; + // 抠图创建时该位置对应的原始距离乘子(0.5+depth01),用于校准:该处 distanceScale==1.0。0 表示未校准(兼容旧工程) + double distanceScaleCalibMult = 0.0; struct KeyframeVec2 { int frame = 0; @@ -76,6 +81,8 @@ public: QVector depthScaleKeys; QVector userScaleKeys; QVector imageFrames; + + EntityIntroContent intro; }; void setEntities(const QVector& entities) { m_entities = entities; } diff --git a/client/core/persistence/EntityPayloadBinary.cpp b/client/core/persistence/EntityPayloadBinary.cpp index 49746c8..a756f52 100644 --- a/client/core/persistence/EntityPayloadBinary.cpp +++ b/client/core/persistence/EntityPayloadBinary.cpp @@ -30,6 +30,36 @@ void sortByFrame(QVector& v) { std::sort(v.begin(), v.end(), [](const auto& a, const auto& b) { return a.frame < b.frame; }); } +void writeIntroBlock(QDataStream& ds, const Project::Entity& entity) { + ds << entity.intro.title << entity.intro.bodyText; + ds << qint32(entity.intro.imagePathsRelative.size()); + for (const auto& p : entity.intro.imagePathsRelative) { + ds << p; + } + ds << entity.intro.videoPathRelative; +} + +bool readIntroBlock(QDataStream& ds, EntityIntroContent& intro) { + ds >> intro.title >> intro.bodyText; + qint32 n = 0; + ds >> n; + if (ds.status() != QDataStream::Ok || n < 0 || n > 2048) { + return false; + } + intro.imagePathsRelative.clear(); + intro.imagePathsRelative.reserve(n); + for (qint32 i = 0; i < n; ++i) { + QString p; + ds >> p; + if (ds.status() != QDataStream::Ok) { + return false; + } + intro.imagePathsRelative.push_back(std::move(p)); + } + ds >> intro.videoPathRelative; + return ds.status() == QDataStream::Ok; +} + bool readAnimationBlock(QDataStream& ds, Project::Entity& out, bool hasUserScaleKeys) { out.locationKeys.clear(); out.depthScaleKeys.clear(); @@ -220,7 +250,8 @@ public: } writeAnimationBlock(ds, entity, true); - ds << entity.displayName << double(entity.userScale); + ds << entity.displayName << double(entity.userScale) << double(entity.distanceScaleCalibMult); + writeIntroBlock(ds, entity); } bool readBody(QDataStream& ds) override { @@ -231,12 +262,17 @@ public: } QString dn; double us = 1.0; - ds >> dn >> us; + double cal = 0.0; + ds >> dn >> us >> cal; if (ds.status() != QDataStream::Ok) { return false; } tmp.displayName = dn; tmp.userScale = std::clamp(us, 1e-3, 1e3); + tmp.distanceScaleCalibMult = (cal > 0.0) ? std::clamp(cal, 1e-6, 10.0) : 0.0; + if (!readIntroBlock(ds, tmp.intro)) { + return false; + } *m_dst = std::move(tmp); return true; } @@ -293,7 +329,7 @@ bool EntityPayloadBinary::load(const QString& absolutePath, Project::Entity& ent if (ds.status() != QDataStream::Ok || magic != kMagicPayload) { return false; } - if (ver != 1 && ver != 2 && ver != 3) { + if (ver != 1 && ver != 2 && ver != 3 && ver != 4 && ver != 5) { return false; } Project::Entity tmp; @@ -309,6 +345,19 @@ bool EntityPayloadBinary::load(const QString& absolutePath, Project::Entity& ent } tmp.displayName = dn; tmp.userScale = std::clamp(us, 1e-3, 1e3); + if (ver >= 4) { + double cal = 0.0; + ds >> cal; + if (ds.status() != QDataStream::Ok) { + return false; + } + tmp.distanceScaleCalibMult = (cal > 0.0) ? std::clamp(cal, 1e-6, 10.0) : 0.0; + } + if (ver >= 5) { + if (!readIntroBlock(ds, tmp.intro)) { + return false; + } + } } else { tmp.displayName.clear(); tmp.userScale = 1.0; diff --git a/client/core/persistence/EntityPayloadBinary.h b/client/core/persistence/EntityPayloadBinary.h index 8770295..598dbc6 100644 --- a/client/core/persistence/EntityPayloadBinary.h +++ b/client/core/persistence/EntityPayloadBinary.h @@ -12,7 +12,7 @@ namespace core { class EntityPayloadBinary { public: static constexpr quint32 kMagicPayload = 0x48464550; // 'HFEP' - static constexpr quint32 kPayloadVersion = 3; // v3:追加 userScaleKeys(动画轨道) + static constexpr quint32 kPayloadVersion = 5; // v5:追加 intro(预览介绍:标题/正文/配图/视频路径预留) // 旧版独立动画文件(仍用于打开 v1 项目时合并) static constexpr quint32 kMagicLegacyAnim = 0x48465441; // 'HFTA' diff --git a/client/core/workspace/ProjectWorkspace.cpp b/client/core/workspace/ProjectWorkspace.cpp index 1f0df06..fd1a743 100644 --- a/client/core/workspace/ProjectWorkspace.cpp +++ b/client/core/workspace/ProjectWorkspace.cpp @@ -1031,7 +1031,59 @@ bool ProjectWorkspace::setEntityDisplayName(const QString& id, const QString& di return applyEntities(ents, true, QStringLiteral("重命名实体")); } -bool ProjectWorkspace::setEntityUserScale(const QString& id, double userScale) { +bool ProjectWorkspace::setEntityIntroContent(const QString& id, const EntityIntroContent& intro) { + if (m_projectDir.isEmpty() || id.isEmpty()) { + return false; + } + auto ents = m_project.entities(); + bool found = false; + for (auto& e : ents) { + if (e.id != id) { + continue; + } + found = true; + e.intro = intro; + break; + } + if (!found) { + return false; + } + return applyEntities(ents, true, QStringLiteral("实体介绍")); +} + +bool ProjectWorkspace::importEntityIntroImageFromFile(const QString& id, const QString& absoluteImagePath, + QString* outRelativePath) { + if (m_projectDir.isEmpty() || id.isEmpty() || absoluteImagePath.isEmpty()) { + return false; + } + const QFileInfo srcFi(absoluteImagePath); + if (!srcFi.exists() || !srcFi.isFile()) { + return false; + } + const QString entsDir = ensureEntitiesDir(); + if (entsDir.isEmpty()) { + return false; + } + const QString suf = fileSuffixWithDot(absoluteImagePath); + for (int n = 0; n < 100000; ++n) { + const QString base = id + QStringLiteral("-intro-%1").arg(n) + suf; + const QString destAbs = QDir(entsDir).filePath(base); + if (!QFileInfo::exists(destAbs)) { + if (!QFile::copy(absoluteImagePath, destAbs)) { + return false; + } + const QString rel = + QString::fromUtf8(kAssetsDirName) + QStringLiteral("/entities/") + base; + if (outRelativePath) { + *outRelativePath = rel; + } + return true; + } + } + return false; +} + +bool ProjectWorkspace::setEntityUserScale(const QString& id, double userScale, int keyframeAtFrame) { if (m_projectDir.isEmpty() || id.isEmpty()) { return false; } @@ -1043,10 +1095,13 @@ bool ProjectWorkspace::setEntityUserScale(const QString& id, double userScale) { continue; } found = true; - if (qFuzzyCompare(e.userScale + 1.0, u + 1.0)) { + const bool baseSame = qFuzzyCompare(e.userScale + 1.0, u + 1.0); + e.userScale = u; + if (keyframeAtFrame >= 0) { + upsertKey(e.userScaleKeys, keyframeAtFrame, std::clamp(u, 1e-6, 1e3)); + } else if (baseSame) { return true; } - e.userScale = u; break; } if (!found) { @@ -1196,8 +1251,17 @@ bool ProjectWorkspace::moveEntityBy(const QString& id, const QPointF& delta, int e.originWorld, KeyInterpolation::Linear); upsertKey(e.locationKeys, currentFrame, sampled + delta); + } else if (e.locationKeys.isEmpty()) { + e.originWorld += delta; + e.imageTopLeftWorld += delta; + } else if (currentFrame >= 0) { + // 已有位置曲线但未勾选「自动关键帧」时,仍应移动当前帧上的位置,否则画布仍按关键帧插值,看起来不生效 + const QPointF sampled = sampleLocation(e.locationKeys, + currentFrame, + e.originWorld, + KeyInterpolation::Linear); + upsertKey(e.locationKeys, currentFrame, sampled + delta); } else { - // 无自动关键帧时,直接修改“基准”位置 e.originWorld += delta; e.imageTopLeftWorld += delta; } diff --git a/client/core/workspace/ProjectWorkspace.h b/client/core/workspace/ProjectWorkspace.h index adce2d8..6c507f6 100644 --- a/client/core/workspace/ProjectWorkspace.h +++ b/client/core/workspace/ProjectWorkspace.h @@ -77,7 +77,8 @@ public: bool addEntity(const Project::Entity& entity, const QImage& image); bool setEntityVisible(const QString& id, bool on); bool setEntityDisplayName(const QString& id, const QString& displayName); - bool setEntityUserScale(const QString& id, double userScale); + /// keyframeAtFrame >= 0 时同时写入该帧的 userScale 关键帧(与画布 sampleUserScale 一致) + bool setEntityUserScale(const QString& id, double userScale, int keyframeAtFrame = -1); // 将多边形质心平移到 targetCentroidWorld(整体平移);sTotal 须与画布一致 bool moveEntityCentroidTo(const QString& id, int frame, const QPointF& targetCentroidWorld, double sTotal, bool autoKeyLocation); @@ -95,6 +96,11 @@ public: bool removeEntityUserScaleKey(const QString& id, int frame); bool removeEntityImageFrame(const QString& id, int frame); + bool setEntityIntroContent(const QString& id, const EntityIntroContent& intro); + /// 将外部图片拷贝到 assets/entities/ 并返回相对项目根的路径 + bool importEntityIntroImageFromFile(const QString& id, const QString& absoluteImagePath, + QString* outRelativePath = nullptr); + private: bool writeIndexJson(); bool readIndexJson(const QString& indexPath); diff --git a/client/gui/CMakeLists.txt b/client/gui/CMakeLists.txt index e25d956..f7bc514 100644 --- a/client/gui/CMakeLists.txt +++ b/client/gui/CMakeLists.txt @@ -9,6 +9,7 @@ set(GUI_SOURCES ${GUI_ROOT}/dialogs/ImageCropDialog.cpp ${GUI_ROOT}/dialogs/FrameAnimationDialog.cpp ${GUI_ROOT}/dialogs/CancelableTaskDialog.cpp + ${GUI_ROOT}/dialogs/EntityIntroPopup.cpp ${GUI_ROOT}/editor/EditorCanvas.cpp ${GUI_ROOT}/params/ParamControls.cpp ${GUI_ROOT}/props/BackgroundPropertySection.cpp @@ -23,6 +24,7 @@ set(GUI_HEADERS ${GUI_ROOT}/dialogs/ImageCropDialog.h ${GUI_ROOT}/dialogs/FrameAnimationDialog.h ${GUI_ROOT}/dialogs/CancelableTaskDialog.h + ${GUI_ROOT}/dialogs/EntityIntroPopup.h ${GUI_ROOT}/editor/EditorCanvas.h ${GUI_ROOT}/params/ParamControls.h ${GUI_ROOT}/props/BackgroundPropertySection.h diff --git a/client/gui/dialogs/EntityIntroPopup.cpp b/client/gui/dialogs/EntityIntroPopup.cpp new file mode 100644 index 0000000..1d46d66 --- /dev/null +++ b/client/gui/dialogs/EntityIntroPopup.cpp @@ -0,0 +1,176 @@ +#include "dialogs/EntityIntroPopup.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace gui { + +namespace { + +constexpr int kMaxThumb = 200; + +QString elideLine(const QString& s, int maxChars) { + if (s.size() <= maxChars) { + return s; + } + return s.left(maxChars - 1) + QChar(0x2026); +} + +} // namespace + +EntityIntroPopup::EntityIntroPopup(QWidget* parent) + : QWidget(parent) { + setAttribute(Qt::WA_TranslucentBackground, false); + setWindowFlags(Qt::Tool | Qt::FramelessWindowHint); + + auto* root = new QVBoxLayout(this); + root->setContentsMargins(14, 12, 14, 12); + root->setSpacing(8); + + m_title = new QLabel(this); + QFont tf = m_title->font(); + tf.setBold(true); + tf.setPointSizeF(tf.pointSizeF() + 1.5); + m_title->setFont(tf); + m_title->setWordWrap(true); + root->addWidget(m_title); + + m_body = new QTextEdit(this); + m_body->setReadOnly(true); + m_body->setFrameShape(QFrame::NoFrame); + m_body->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + m_body->setMaximumHeight(180); + m_body->setPlaceholderText(QStringLiteral("(无)")); + root->addWidget(m_body); + + m_imagesHost = new QWidget(this); + m_imagesLayout = new QVBoxLayout(m_imagesHost); + m_imagesLayout->setContentsMargins(0, 0, 0, 0); + m_imagesLayout->setSpacing(6); + root->addWidget(m_imagesHost); + + m_videoHint = new QLabel(this); + m_videoHint->setWordWrap(true); + m_videoHint->setStyleSheet(QStringLiteral("QLabel { color: palette(mid); font-size: 11px; }")); + root->addWidget(m_videoHint); + + setMinimumWidth(280); + setMaximumWidth(420); + hide(); +} + +void EntityIntroPopup::setProjectDir(const QString& absoluteProjectDir) { + m_projectDirAbs = absoluteProjectDir; +} + +void EntityIntroPopup::setContent(const core::EntityIntroContent& content) { + const QString t = content.title.trimmed().isEmpty() + ? QStringLiteral("介绍") + : content.title.trimmed(); + m_title->setText(elideLine(t, 200)); + m_body->setPlainText(content.bodyText); + + while (QLayoutItem* it = m_imagesLayout->takeAt(0)) { + if (QWidget* w = it->widget()) { + w->deleteLater(); + } + delete it; + } + + for (const QString& rel : content.imagePathsRelative) { + if (rel.isEmpty()) { + continue; + } + const QString abs = + m_projectDirAbs.isEmpty() ? QString() : QDir(m_projectDirAbs).filePath(rel); + auto* lab = new QLabel(m_imagesHost); + lab->setAlignment(Qt::AlignCenter); + if (!abs.isEmpty() && QFileInfo::exists(abs)) { + QPixmap pm(abs); + if (!pm.isNull()) { + if (pm.width() > kMaxThumb || pm.height() > kMaxThumb) { + pm = pm.scaled(kMaxThumb, kMaxThumb, Qt::KeepAspectRatio, Qt::SmoothTransformation); + } + lab->setPixmap(pm); + } else { + lab->setText(QStringLiteral("(无法加载图片)")); + } + } else { + lab->setText(QStringLiteral("(缺少文件:%1)").arg(rel)); + } + lab->setWordWrap(true); + m_imagesLayout->addWidget(lab); + } + + if (content.videoPathRelative.trimmed().isEmpty()) { + m_videoHint->setText(QStringLiteral("视频介绍")); + } else { + m_videoHint->setText(QStringLiteral("播放功能预留").arg(content.videoPathRelative)); + } + adjustSize(); +} + +void EntityIntroPopup::showNearCanvasPoint(const QPoint& viewPos, QWidget* canvasWidget) { + if (!canvasWidget) { + return; + } + adjustSize(); + const QPoint globalAnchor = canvasWidget->mapToGlobal(viewPos); + QScreen* scr = QGuiApplication::screenAt(globalAnchor); + if (!scr) { + scr = canvasWidget->screen(); + } + const QRect screen = scr ? scr->availableGeometry() : QRect(0, 0, 1920, 1080); + + int x = globalAnchor.x() + 18; + int y = globalAnchor.y() - height() / 3; + if (x + width() > screen.right()) { + x = globalAnchor.x() - width() - 18; + } + if (x < screen.left()) { + x = screen.left() + 8; + } + if (y + height() > screen.bottom()) { + y = screen.bottom() - height() - 8; + } + if (y < screen.top()) { + y = screen.top() + 8; + } + move(x, y); + show(); + raise(); +} + +void EntityIntroPopup::clearAndHide() { + hide(); + m_title->clear(); + m_body->clear(); + while (QLayoutItem* it = m_imagesLayout->takeAt(0)) { + if (QWidget* w = it->widget()) { + w->deleteLater(); + } + delete it; + } + m_videoHint->clear(); +} + +void EntityIntroPopup::paintEvent(QPaintEvent* e) { + Q_UNUSED(e); + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing, true); + const QRect r = rect().adjusted(1, 1, -1, -1); + p.fillRect(r, palette().base()); + p.setPen(QPen(palette().mid().color(), 1)); + p.drawRoundedRect(r, 8, 8); +} + +} // namespace gui diff --git a/client/gui/dialogs/EntityIntroPopup.h b/client/gui/dialogs/EntityIntroPopup.h new file mode 100644 index 0000000..53f7fe2 --- /dev/null +++ b/client/gui/dialogs/EntityIntroPopup.h @@ -0,0 +1,37 @@ +#pragma once + +#include "core/domain/EntityIntro.h" + +#include + +class QLabel; +class QTextEdit; +class QVBoxLayout; + +namespace gui { + +/// 预览模式下在实体附近显示的介绍浮层(非模态,可随内容伸缩) +class EntityIntroPopup final : public QWidget { + Q_OBJECT +public: + explicit EntityIntroPopup(QWidget* parent = nullptr); + + void setProjectDir(const QString& absoluteProjectDir); + void setContent(const core::EntityIntroContent& content); + void showNearCanvasPoint(const QPoint& viewPos, QWidget* canvasWidget); + + void clearAndHide(); + +protected: + void paintEvent(QPaintEvent* e) override; + +private: + QString m_projectDirAbs; + QLabel* m_title = nullptr; + QTextEdit* m_body = nullptr; + QWidget* m_imagesHost = nullptr; + QVBoxLayout* m_imagesLayout = nullptr; + QLabel* m_videoHint = nullptr; +}; + +} // namespace gui diff --git a/client/gui/editor/EditorCanvas.cpp b/client/gui/editor/EditorCanvas.cpp index 9adb983..cc115e6 100644 --- a/client/gui/editor/EditorCanvas.cpp +++ b/client/gui/editor/EditorCanvas.cpp @@ -13,6 +13,7 @@ #include #include #include +#include namespace { @@ -163,6 +164,16 @@ double depthToScale01(int depthZ) { return static_cast(d) / 255.0; } +// depth01 0..1 -> 原始距离乘子 0.5..1.5;calibMult>0 时除以创建时记录的基准,使「原位置」为 1.0 +double distanceScaleFromDepth01(double depth01, double calibMult) { + const double d = std::clamp(depth01, 0.0, 1.0); + const double raw = 0.5 + d * 1.0; + if (calibMult > 0.0) { + return raw / std::max(calibMult, 1e-6); + } + return raw; +} + QImage extractEntityImage(const QImage& bg, const QVector& polyWorld, QPointF& outTopLeftWorld) { if (bg.isNull() || polyWorld.size() < 3) { outTopLeftWorld = {}; @@ -246,6 +257,15 @@ EditorCanvas::EditorCanvas(QWidget* parent) setMouseTracking(true); m_previewEmitTimer.start(); + + m_presZoomTimer = new QTimer(this); + m_presZoomTimer->setInterval(16); + connect(m_presZoomTimer, &QTimer::timeout, this, &EditorCanvas::tickPresentationZoomAnimation); + + m_presHoverTimer = new QTimer(this); + m_presHoverTimer->setInterval(40); + connect(m_presHoverTimer, &QTimer::timeout, this, &EditorCanvas::tickPresentationHoverAnimation); + updateCursor(); } @@ -254,6 +274,19 @@ void EditorCanvas::setPresentationPreviewMode(bool on) { return; } m_presentationPreviewMode = on; + if (m_presZoomTimer) { + m_presZoomTimer->stop(); + } + if (m_presHoverTimer) { + m_presHoverTimer->stop(); + } + m_presHoverEntityIndex = -1; + m_presFocusedEntityIndex = -1; + m_presHoverPhase = 0.0; + m_presZoomAnimT = 0.0; + m_presZoomFinishingRestore = false; + m_presBgPanSession = false; + m_presBgDragDist = 0.0; if (on) { m_tool = Tool::Move; m_selectedEntity = -1; @@ -292,6 +325,7 @@ void EditorCanvas::setEntities(const QVector& entities, c core::sampleLocation(e.locationKeys, m_currentFrame, e.originWorld, KeyInterpolation::Linear); v.animatedOriginWorld = originWorld; v.cutoutPolygonWorld = e.cutoutPolygonWorld; + v.distanceScaleCalibMult = e.distanceScaleCalibMult; // 逐帧自动算 z:使用实体多边形质心作为锚点采样深度(O(1)),避免卡顿 QVector polyTmp; @@ -308,7 +342,7 @@ void EditorCanvas::setEntities(const QVector& entities, c const double userScaleAnimated = core::sampleUserScale(e.userScaleKeys, m_currentFrame, e.userScale, core::KeyInterpolation::Linear); v.userScale = std::max(1e-6, userScaleAnimated); - const double distScale = 0.5 + ds01 * 1.0; // 0..1 -> 0.5..1.5(深度距离) + const double distScale = distanceScaleFromDepth01(ds01, e.distanceScaleCalibMult); const double scale = distScale * v.userScale; v.visualScale = scale; @@ -412,8 +446,8 @@ double EditorCanvas::selectedDistanceScaleMultiplier() const { if (m_selectedEntity < 0 || m_selectedEntity >= m_entities.size()) { return 1.0; } - const double ds01 = std::clamp(m_entities[m_selectedEntity].animatedDepthScale01, 0.0, 1.0); - return 0.5 + ds01 * 1.0; + const auto& ent = m_entities[m_selectedEntity]; + return distanceScaleFromDepth01(ent.animatedDepthScale01, ent.distanceScaleCalibMult); } double EditorCanvas::selectedUserScale() const { @@ -427,6 +461,95 @@ double EditorCanvas::selectedCombinedScale() const { return selectedDistanceScaleMultiplier() * selectedUserScale(); } +void EditorCanvas::tickPresentationHoverAnimation() { + if (!m_presentationPreviewMode) { + return; + } + m_presHoverPhase += 0.35; + if (m_presHoverPhase > 6.28318530718) { + m_presHoverPhase -= 6.28318530718; + } + update(); +} + +void EditorCanvas::tickPresentationZoomAnimation() { + m_presZoomAnimT += 0.16; + qreal u = std::min(1.0, static_cast(m_presZoomAnimT)); + u = 1.0 - std::pow(1.0 - u, 3.0); + m_pan = m_presZoomFromPan + (m_presZoomToPan - m_presZoomFromPan) * u; + m_scale = m_presZoomFromScale + (m_presZoomToScale - m_presZoomFromScale) * u; + if (m_presZoomAnimT >= 1.0) { + m_presZoomTimer->stop(); + m_pan = m_presZoomToPan; + m_scale = m_presZoomToScale; + if (m_presZoomFinishingRestore) { + m_presFocusedEntityIndex = -1; + m_presZoomFinishingRestore = false; + } + } + update(); +} + +void EditorCanvas::presentationComputeZoomTarget(int entityIndex, QPointF* outPan, qreal* outScale) const { + if (!outPan || !outScale || entityIndex < 0 || entityIndex >= m_entities.size()) { + return; + } + const Entity& ent = m_entities[entityIndex]; + QRectF bb; + if (!ent.image.isNull()) { + const QSizeF sz(ent.image.width() * ent.visualScale, ent.image.height() * ent.visualScale); + bb = QRectF(ent.imageTopLeft, sz); + } else if (!ent.polygonWorld.isEmpty()) { + bb = pathFromWorldPolygon(ent.polygonWorld).boundingRect(); + } else { + bb = ent.rect; + } + const QPointF c = bb.center(); + const qreal rw = std::max(1.0, bb.width()); + const qreal rh = std::max(1.0, bb.height()); + qreal s = std::min(static_cast(width()) / (rw * 1.28), static_cast(height()) / (rh * 1.28)); + s = std::clamp(s, 0.12, 14.0); + *outScale = s; + *outPan = QPointF(width() / 2.0, height() / 2.0) - c * s; +} + +void EditorCanvas::beginPresentationZoomTowardEntity(int entityIndex) { + if (entityIndex < 0 || entityIndex >= m_entities.size()) { + return; + } + if (m_presFocusedEntityIndex < 0) { + m_presRestorePan = m_pan; + m_presRestoreScale = m_scale; + } + m_presFocusedEntityIndex = entityIndex; + m_presZoomFromPan = m_pan; + m_presZoomFromScale = m_scale; + presentationComputeZoomTarget(entityIndex, &m_presZoomToPan, &m_presZoomToScale); + m_presZoomAnimT = 0.0; + m_presZoomFinishingRestore = false; + m_presZoomTimer->start(); +} + +void EditorCanvas::beginPresentationZoomRestore() { + m_presZoomFromPan = m_pan; + m_presZoomFromScale = m_scale; + m_presZoomToPan = m_presRestorePan; + m_presZoomToScale = m_presRestoreScale; + m_presZoomAnimT = 0.0; + m_presZoomFinishingRestore = true; + m_presZoomTimer->start(); +} + +void EditorCanvas::clearPresentationEntityFocus() { + emit presentationInteractionDismissed(); + if (m_presZoomFinishingRestore) { + return; + } + if (m_presFocusedEntityIndex >= 0) { + beginPresentationZoomRestore(); + } +} + void EditorCanvas::clearEntitySelection() { if (m_selectedEntity < 0) { return; @@ -598,7 +721,11 @@ void EditorCanvas::ensurePixmapLoaded() const { void EditorCanvas::updateCursor() { if (m_presentationPreviewMode) { - setCursor(Qt::OpenHandCursor); + if (m_presHoverEntityIndex >= 0) { + setCursor(Qt::PointingHandCursor); + } else { + setCursor(Qt::OpenHandCursor); + } return; } switch (m_tool) { @@ -784,8 +911,18 @@ void EditorCanvas::paintEvent(QPaintEvent* e) { p.drawImage(target, ent.image); p.restore(); } else { - const QSizeF sz(ent.image.width() * ent.visualScale, ent.image.height() * ent.visualScale); - const QRectF target(ent.imageTopLeft, sz); + const qreal pop = + (m_presentationPreviewMode && i == m_presFocusedEntityIndex) ? 1.1 : 1.0; + const QSizeF sz0(ent.image.width() * ent.visualScale, ent.image.height() * ent.visualScale); + QRectF target; + if (pop > 1.001) { + const QRectF orig(ent.imageTopLeft, sz0); + const QPointF cen = orig.center(); + const QSizeF sz = orig.size() * pop; + target = QRectF(QPointF(cen.x() - sz.width() * 0.5, cen.y() - sz.height() * 0.5), sz); + } else { + target = QRectF(ent.imageTopLeft, sz0); + } p.drawImage(target, ent.image); } } else { @@ -853,6 +990,33 @@ void EditorCanvas::paintEvent(QPaintEvent* e) { p.drawRect(ent.rect.adjusted(-2, -2, 2, 2)); } } + if (m_presentationPreviewMode && !ent.hiddenInEditMode) { + const bool showHover = (i == m_presHoverEntityIndex); + const bool showFocus = (i == m_presFocusedEntityIndex); + if (showHover || showFocus) { + p.setBrush(Qt::NoBrush); + if (showHover) { + const qreal pulse = 0.45 + 0.55 * std::sin(static_cast(m_presHoverPhase)); + const qreal lw = + (2.0 + 2.8 * pulse) / std::max(static_cast(m_scale), static_cast(0.001)); + p.setPen(QPen(QColor(255, 210, 80, static_cast(65 + 110 * pulse)), lw)); + if (!ent.pathWorld.isEmpty()) { + p.drawPath(ent.pathWorld); + } else { + p.drawRect(ent.rect); + } + } + if (showFocus) { + const qreal lw = 2.8 / std::max(static_cast(m_scale), static_cast(0.001)); + p.setPen(QPen(QColor(255, 120, 40, 230), lw)); + if (!ent.pathWorld.isEmpty()) { + p.drawPath(ent.pathWorld); + } else { + p.drawRect(ent.rect); + } + } + } + } } // 创建实体:手绘轨迹预览(world 坐标) @@ -986,8 +1150,27 @@ void EditorCanvas::mousePressEvent(QMouseEvent* e) { emit hoveredWorldPosDepthChanged(wp0, z0); if (m_presentationPreviewMode) { - if (e->button() == Qt::LeftButton || e->button() == Qt::MiddleButton) { + if (e->button() == Qt::LeftButton) { + const int hit = hitTestEntity(wp0); + if (hit >= 0) { + const auto& ent = m_entities[hit]; + const QPointF cWorld = + ent.polygonWorld.isEmpty() ? ent.rect.center() : polygonCentroid(ent.polygonWorld); + const QPointF anchorView = worldToView(cWorld); + beginPresentationZoomTowardEntity(hit); + emit presentationEntityIntroRequested(ent.id, anchorView); + return; + } m_dragging = true; + m_presBgPanSession = true; + m_presBgDragDist = 0.0; + m_lastMouseView = e->position(); + setCursor(Qt::ClosedHandCursor); + return; + } + if (e->button() == Qt::MiddleButton) { + m_dragging = true; + m_presBgPanSession = false; m_lastMouseView = e->position(); setCursor(Qt::ClosedHandCursor); } @@ -1117,6 +1300,23 @@ void EditorCanvas::mouseMoveEvent(QMouseEvent* e) { } emit hoveredWorldPosDepthChanged(wp, z); + if (m_presentationPreviewMode) { + const int h = hitTestEntity(wp); + if (h != m_presHoverEntityIndex) { + m_presHoverEntityIndex = h; + updateCursor(); + update(); + } + if (h >= 0) { + if (m_presHoverTimer && !m_presHoverTimer->isActive()) { + m_presHoverTimer->start(); + } + } else if (m_presHoverTimer) { + m_presHoverTimer->stop(); + m_presHoverPhase = 0.0; + } + } + if (!m_dragging) { QWidget::mouseMoveEvent(e); return; @@ -1198,7 +1398,8 @@ void EditorCanvas::mouseMoveEvent(QMouseEvent* e) { const double ds01 = depthToScale01(depthZ); ent.animatedDepthScale01 = ds01; - const double newScale = (0.5 + ds01 * 1.0) * ent.userScale; + const double newScale = + distanceScaleFromDepth01(ds01, ent.distanceScaleCalibMult) * ent.userScale; ent.visualScale = newScale; if (m_dragPreviewActive) { m_dragScaleRatio = std::clamp(newScale / std::max(1e-6, m_dragScaleBase), 0.02, 50.0); @@ -1218,6 +1419,9 @@ void EditorCanvas::mouseMoveEvent(QMouseEvent* e) { // 平移画布 if (m_tool == Tool::Move || (e->buttons() & Qt::MiddleButton) || (m_presentationPreviewMode && (e->buttons() & Qt::LeftButton))) { + if (m_presentationPreviewMode && m_presBgPanSession) { + m_presBgDragDist += std::abs(deltaView.x()) + std::abs(deltaView.y()); + } m_pan += deltaView; update(); return; @@ -1226,6 +1430,13 @@ void EditorCanvas::mouseMoveEvent(QMouseEvent* e) { void EditorCanvas::mouseReleaseEvent(QMouseEvent* e) { if (e->button() == Qt::LeftButton || e->button() == Qt::MiddleButton) { + if (m_presentationPreviewMode && e->button() == Qt::LeftButton) { + if (m_presBgPanSession && m_presBgDragDist < 8.0) { + clearPresentationEntityFocus(); + } + m_presBgPanSession = false; + m_presBgDragDist = 0.0; + } if (m_tool == Tool::CreateEntity && e->button() == Qt::LeftButton && m_drawingEntity) { m_dragging = false; m_drawingEntity = false; @@ -1252,6 +1463,10 @@ void EditorCanvas::mouseReleaseEvent(QMouseEvent* e) { } const QPointF c = polygonCentroid(ent.cutoutPolygonWorld); ent.depth = m_depthImage8.isNull() ? 0 : sampleDepthAtPoint(m_depthImage8, c); + { + const double ds01 = depthToScale01(ent.depth); + ent.distanceScaleCalibMult = 0.5 + ds01 * 1.0; + } // 抠实体:从原背景抠出,背景对应区域填黑 QImage cutout; @@ -1325,3 +1540,12 @@ void EditorCanvas::wheelEvent(QWheelEvent* e) { e->accept(); } +void EditorCanvas::keyPressEvent(QKeyEvent* e) { + if (m_presentationPreviewMode && e->key() == Qt::Key_Escape) { + clearPresentationEntityFocus(); + e->accept(); + return; + } + QWidget::keyPressEvent(e); +} + diff --git a/client/gui/editor/EditorCanvas.h b/client/gui/editor/EditorCanvas.h index af48331..3a2c985 100644 --- a/client/gui/editor/EditorCanvas.h +++ b/client/gui/editor/EditorCanvas.h @@ -9,6 +9,9 @@ #include #include #include +#include + +class QKeyEvent; class EditorCanvas final : public QWidget { Q_OBJECT @@ -51,6 +54,8 @@ public: // 预览呈现:完整背景 + 全部实体(忽略显隐开关),隐藏编辑辅助元素,仅可平移/缩放查看 void setPresentationPreviewMode(bool on); bool presentationPreviewMode() const { return m_presentationPreviewMode; } + /// 退出「点击实体放大」状态并平滑回到进入前的视图(预览模式) + void clearPresentationEntityFocus(); void setEntities(const QVector& entities, const QString& projectDirAbs); void setCurrentFrame(int frame); @@ -79,6 +84,10 @@ signals: void requestMoveEntity(const QString& id, const QPointF& delta); void entityDragActiveChanged(bool on); void selectedEntityPreviewChanged(const QString& id, int depth, const QPointF& originWorld); + /// 预览模式下点击实体:anchorView 为实体质心在视图中的位置,用于摆放介绍浮层 + void presentationEntityIntroRequested(const QString& entityId, QPointF anchorViewPoint); + /// 预览模式下应关闭介绍层(空白处轻点、Esc、开始还原缩放时由画布侧触发) + void presentationInteractionDismissed(); protected: void paintEvent(QPaintEvent* e) override; @@ -87,6 +96,7 @@ protected: void mouseMoveEvent(QMouseEvent* e) override; void mouseReleaseEvent(QMouseEvent* e) override; void wheelEvent(QWheelEvent* e) override; + void keyPressEvent(QKeyEvent* e) override; private: void ensurePixmapLoaded() const; @@ -97,6 +107,12 @@ private: QPointF worldToView(const QPointF& w) const; QRectF worldRectOfBackground() const; + void tickPresentationZoomAnimation(); + void tickPresentationHoverAnimation(); + void beginPresentationZoomTowardEntity(int entityIndex); + void beginPresentationZoomRestore(); + void presentationComputeZoomTarget(int entityIndex, QPointF* outPan, qreal* outScale) const; + private: struct Entity { QString id; @@ -112,6 +128,7 @@ private: QPointF imageTopLeft; // image 对应的 world 左上角 double visualScale = 1.0; // 实体在 world 坐标下的缩放(用于贴图绘制) double userScale = 1.0; // 与深度距离缩放相乘 + double distanceScaleCalibMult = 0.0; // 与 Project::Entity 一致;0=未校准 QPointF animatedOriginWorld; double animatedDepthScale01 = 0.5; // 编辑模式下实体被设为隐藏时:不响应点选且不绘制,除非当前选中(便于树选隐藏实体) @@ -175,5 +192,22 @@ private: QVector m_strokeWorld; int m_currentFrame = 0; + + // —— 预览展示:实体悬停动效、点击聚焦缩放 —— + QTimer* m_presZoomTimer = nullptr; + QTimer* m_presHoverTimer = nullptr; + int m_presHoverEntityIndex = -1; + int m_presFocusedEntityIndex = -1; + qreal m_presHoverPhase = 0.0; + QPointF m_presRestorePan; + qreal m_presRestoreScale = 1.0; + QPointF m_presZoomFromPan; + QPointF m_presZoomToPan; + qreal m_presZoomFromScale = 1.0; + qreal m_presZoomToScale = 1.0; + qreal m_presZoomAnimT = 0.0; + bool m_presZoomFinishingRestore = false; + bool m_presBgPanSession = false; + qreal m_presBgDragDist = 0.0; }; diff --git a/client/gui/main_window/MainWindow.cpp b/client/gui/main_window/MainWindow.cpp index e0c2952..6f678a9 100644 --- a/client/gui/main_window/MainWindow.cpp +++ b/client/gui/main_window/MainWindow.cpp @@ -3,14 +3,18 @@ #include "dialogs/CancelableTaskDialog.h" #include "editor/EditorCanvas.h" #include "dialogs/ImageCropDialog.h" +#include "core/domain/EntityIntro.h" #include "core/net/ModelServerClient.h" #include "params/ParamControls.h" #include "props/BackgroundPropertySection.h" #include "props/EntityPropertySection.h" #include "timeline/TimelineWidget.h" #include "dialogs/FrameAnimationDialog.h" +#include "dialogs/EntityIntroPopup.h" #include +#include +#include #include #include #include @@ -43,7 +47,10 @@ #include #include #include +#include #include +#include +#include #include #include #include @@ -130,6 +137,31 @@ const char* kFloatingModeDockQss = R"( } )"; +/// 避免滚轮先被 QScrollArea 吃掉,导致内嵌 QDoubleSpinBox 无法用滚轮调节 +class SpinFriendlyScrollArea final : public QScrollArea { +public: + explicit SpinFriendlyScrollArea(QWidget* parent = nullptr) + : QScrollArea(parent) {} + +protected: + void wheelEvent(QWheelEvent* e) override { +#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) + const QPoint inVp = viewport()->mapFrom(this, e->position().toPoint()); +#else + const QPoint inVp = viewport()->mapFrom(this, e->pos()); +#endif + if (QWidget* w = viewport()->childAt(inVp)) { + for (QWidget* cur = w; cur; cur = cur->parentWidget()) { + if (qobject_cast(cur)) { + QApplication::sendEvent(cur, e); + return; + } + } + } + QScrollArea::wheelEvent(e); + } +}; + const char* kTimelineBarQss = R"( #TimelineDockBar QToolButton, #TimelineDockBar QPushButton { border: 1px solid palette(midlight); @@ -158,6 +190,7 @@ public: EditorCanvas* canvas = nullptr; QWidget* modeDock = nullptr; QWidget* toolDock = nullptr; + QWidget* previewPlaybackBar = nullptr; void relayoutFloaters() { if (canvas) { @@ -201,6 +234,18 @@ public: if (toolDock && toolDock->isVisible()) { toolDock->raise(); } + + if (previewPlaybackBar && previewPlaybackBar->isVisible()) { + if (QLayout* lay = previewPlaybackBar->layout()) { + lay->activate(); + } + previewPlaybackBar->updateGeometry(); + previewPlaybackBar->adjustSize(); + const int x = std::max(kMargin, (width() - previewPlaybackBar->width()) / 2); + const int y = std::max(kMargin, height() - previewPlaybackBar->height() - kMargin); + previewPlaybackBar->move(x, y); + previewPlaybackBar->raise(); + } } protected: @@ -229,6 +274,21 @@ MainWindow::MainWindow(QWidget* parent) createMenus(); createProjectTreeDock(); createTimelineDock(); + + if (m_previewBtnPlay && m_previewBtnPause && m_btnPlay) { + connect(m_previewBtnPlay, &QToolButton::clicked, this, [this]() { + if (m_btnPlay && !m_btnPlay->isChecked()) { + m_btnPlay->setChecked(true); + } + }); + connect(m_previewBtnPause, &QToolButton::clicked, this, [this]() { + if (m_btnPlay && m_btnPlay->isChecked()) { + m_btnPlay->setChecked(false); + } + }); + } + syncPreviewPlaybackBar(); + refreshProjectTree(); updateUiEnabledState(); refreshEditorPage(); @@ -604,6 +664,15 @@ void MainWindow::onTogglePlay(bool on) { m_playTimer->stop(); } } + syncPreviewPlaybackBar(); +} + +void MainWindow::syncPreviewPlaybackBar() { + if (!m_previewBtnPlay || !m_previewBtnPause) { + return; + } + m_previewBtnPlay->setEnabled(!m_playing); + m_previewBtnPause->setEnabled(m_playing); } void MainWindow::onInsertCombinedKey() { @@ -860,7 +929,14 @@ void MainWindow::createProjectTreeDock() { } }); connect(m_projectTree, &QTreeWidget::itemClicked, this, &MainWindow::onProjectTreeItemClicked); - dockLayout->addWidget(m_projectTree, 1); + + auto* treeScroll = new SpinFriendlyScrollArea(dockContent); + treeScroll->setWidgetResizable(true); + treeScroll->setFrameShape(QFrame::NoFrame); + treeScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded); + treeScroll->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + treeScroll->setWidget(m_projectTree); + dockLayout->addWidget(treeScroll, 1); m_dockProjectTree->setWidget(dockContent); m_dockProjectTree->installEventFilter(this); @@ -875,7 +951,7 @@ void MainWindow::createProjectTreeDock() { m_bgPropertySection = new gui::BackgroundPropertySection(); m_entityPropertySection = new gui::EntityPropertySection(); - m_propertyStack = new QStackedWidget(m_dockProperties); + m_propertyStack = new QStackedWidget(); m_propertyStack->setContentsMargins(4, 4, 4, 4); m_propertyStack->addWidget(m_bgPropertySection); m_propertyStack->addWidget(m_entityPropertySection); @@ -927,11 +1003,48 @@ void MainWindow::createProjectTreeDock() { }); connect(m_entityPropertySection, &gui::EntityPropertySection::userScaleEdited, this, [this](double v) { if (m_selectedEntityId.isEmpty()) return; - if (!m_workspace.setEntityUserScale(m_selectedEntityId, v)) return; + if (!m_workspace.setEntityUserScale(m_selectedEntityId, v, m_currentFrame)) return; + refreshEditorPage(); + refreshDopeSheet(); + }); + connect(m_entityPropertySection, &gui::EntityPropertySection::introContentEdited, this, [this]() { + if (m_selectedEntityId.isEmpty() || !m_entityPropertySection) { + return; + } + const core::EntityIntroContent intro = m_entityPropertySection->introSnapshot(); + if (!m_workspace.setEntityIntroContent(m_selectedEntityId, intro)) { + QMessageBox::warning(this, QStringLiteral("介绍"), QStringLiteral("自动保存失败。")); + return; + } refreshEditorPage(); }); + connect(m_entityPropertySection, &gui::EntityPropertySection::introAddImageRequested, this, [this]() { + if (m_selectedEntityId.isEmpty() || !m_entityPropertySection) { + return; + } + const QString path = QFileDialog::getOpenFileName( + this, + QStringLiteral("选择配图"), + {}, + QStringLiteral("图片 (*.png *.jpg *.jpeg *.webp *.bmp);;所有文件 (*)")); + if (path.isEmpty()) { + return; + } + QString rel; + if (!m_workspace.importEntityIntroImageFromFile(m_selectedEntityId, path, &rel)) { + QMessageBox::warning(this, QStringLiteral("介绍"), QStringLiteral("导入配图失败。")); + return; + } + m_entityPropertySection->appendIntroImagePath(rel); + }); - m_dockProperties->setWidget(m_propertyStack); + auto* propScroll = new SpinFriendlyScrollArea(m_dockProperties); + propScroll->setWidgetResizable(true); + propScroll->setFrameShape(QFrame::NoFrame); + propScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded); + propScroll->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + propScroll->setWidget(m_propertyStack); + m_dockProperties->setWidget(propScroll); addDockWidget(Qt::RightDockWidgetArea, m_dockProjectTree); splitDockWidget(m_dockProjectTree, m_dockProperties, Qt::Vertical); @@ -996,10 +1109,12 @@ void MainWindow::refreshPropertyPanel() { QString displayName; double userScale = 1.0; + core::EntityIntroContent intro; for (const auto& e : m_workspace.entities()) { if (e.id == m_selectedEntityId) { displayName = e.displayName; userScale = e.userScale; + intro = e.intro; break; } } @@ -1013,6 +1128,7 @@ void MainWindow::refreshPropertyPanel() { st.pivot = m_editorCanvas->selectedAnimatedOriginWorld(); st.centroid = m_editorCanvas->selectedEntityCentroidWorld(); st.userScale = userScale; + st.intro = intro; m_entityPropertySection->applyState(st); m_propertyStack->setCurrentWidget(m_entityPropertySection); m_dockProperties->setWindowTitle(QStringLiteral("属性 — 实体")); @@ -1323,6 +1439,9 @@ void MainWindow::applyUiMode(UiMode mode) { if (m_floatingToolDock) { m_floatingToolDock->setVisible(projectOpen && !preview); } + if (m_previewPlaybackBar) { + m_previewPlaybackBar->setVisible(projectOpen && preview); + } if (m_canvasHost) { m_canvasHost->updateGeometry(); static_cast(m_canvasHost)->relayoutFloaters(); @@ -1500,6 +1619,28 @@ void MainWindow::rebuildCentralPages() { m_editorCanvas = new EditorCanvas(canvasHost); canvasHost->canvas = m_editorCanvas; + m_entityIntroPopup = new gui::EntityIntroPopup(this); + + m_previewPlaybackBar = new QFrame(canvasHost); + m_previewPlaybackBar->setObjectName(QStringLiteral("PreviewPlaybackBar")); + m_previewPlaybackBar->setStyleSheet(QString::fromUtf8(kTimelineBarQss)); + auto* pbl = new QHBoxLayout(m_previewPlaybackBar); + pbl->setContentsMargins(10, 6, 10, 6); + pbl->setSpacing(8); + m_previewBtnPlay = new QToolButton(m_previewPlaybackBar); + m_previewBtnPlay->setText(QStringLiteral("播放")); + m_previewBtnPlay->setToolTip(QStringLiteral("播放时间轴")); + m_previewBtnPause = new QToolButton(m_previewPlaybackBar); + m_previewBtnPause->setText(QStringLiteral("暂停")); + m_previewBtnPause->setToolTip(QStringLiteral("暂停时间轴")); + polishCompactToolButton(m_previewBtnPlay, 36); + polishCompactToolButton(m_previewBtnPause, 36); + pbl->addWidget(m_previewBtnPlay); + pbl->addWidget(m_previewBtnPause); + m_previewPlaybackBar->setParent(canvasHost); + m_previewPlaybackBar->hide(); + canvasHost->previewPlaybackBar = m_previewPlaybackBar; + m_floatingModeDock = new QFrame(canvasHost); m_floatingModeDock->setObjectName(QStringLiteral("FloatingModeDock")); m_floatingModeDock->setFrameShape(QFrame::NoFrame); @@ -1685,6 +1826,33 @@ void MainWindow::rebuildCentralPages() { updateUiEnabledState(); }); + connect(m_editorCanvas, &EditorCanvas::presentationEntityIntroRequested, this, + [this](const QString& id, QPointF anchorView) { + if (!m_entityIntroPopup || !m_workspace.isOpen()) { + return; + } + const core::Project::Entity* hit = nullptr; + for (const auto& e : m_workspace.entities()) { + if (e.id == id) { + hit = &e; + break; + } + } + m_entityIntroPopup->setProjectDir(m_workspace.projectDir()); + if (hit) { + m_entityIntroPopup->setContent(hit->intro); + } else { + core::EntityIntroContent empty; + m_entityIntroPopup->setContent(empty); + } + m_entityIntroPopup->showNearCanvasPoint(anchorView.toPoint(), m_editorCanvas); + }); + connect(m_editorCanvas, &EditorCanvas::presentationInteractionDismissed, this, [this]() { + if (m_entityIntroPopup) { + m_entityIntroPopup->clearAndHide(); + } + }); + connect(group, &QButtonGroup::idClicked, this, [this](int id) { if (!m_editorCanvas) { return; @@ -1793,6 +1961,12 @@ void MainWindow::setPreviewRequested(bool preview) { return; } m_previewRequested = preview; + if (!preview && m_editorCanvas) { + m_editorCanvas->clearPresentationEntityFocus(); + } + if (!preview && m_entityIntroPopup) { + m_entityIntroPopup->clearAndHide(); + } updateUiEnabledState(); if (preview) { refreshPreviewPage(); diff --git a/client/gui/main_window/MainWindow.h b/client/gui/main_window/MainWindow.h index 2a0646f..387a0f6 100644 --- a/client/gui/main_window/MainWindow.h +++ b/client/gui/main_window/MainWindow.h @@ -30,6 +30,7 @@ class TimelineWidget; namespace gui { class BackgroundPropertySection; class EntityPropertySection; +class EntityIntroPopup; } class MainWindow : public QMainWindow { @@ -96,6 +97,7 @@ private: void applyTimelineFromProject(); void refreshDopeSheet(); void setPreviewRequested(bool preview); + void syncPreviewPlaybackBar(); QStackedWidget* m_centerStack = nullptr; QWidget* m_pageWelcome = nullptr; @@ -174,4 +176,9 @@ private: // 旧版 DopeSheet 已移除,这里保留占位便于后续扩展区间 UI(如自定义小部件) QTreeWidget* m_dopeTree = nullptr; QPushButton* m_btnDopeDeleteKey = nullptr; + + gui::EntityIntroPopup* m_entityIntroPopup = nullptr; + QFrame* m_previewPlaybackBar = nullptr; + QToolButton* m_previewBtnPlay = nullptr; + QToolButton* m_previewBtnPause = nullptr; }; diff --git a/client/gui/params/ParamControls.cpp b/client/gui/params/ParamControls.cpp index 27e003d..caf7ac6 100644 --- a/client/gui/params/ParamControls.cpp +++ b/client/gui/params/ParamControls.cpp @@ -117,7 +117,11 @@ void Vec2ParamControl::emitIfChanged() { if (m_block || !m_x || !m_y) return; const double nx = m_x->value(); const double ny = m_y->value(); - if (nx == m_lastX && ny == m_lastY) return; + auto nearEq = [](double a, double b) { + const double scale = std::max({1.0, std::abs(a), std::abs(b)}); + return std::abs(a - b) <= 1e-4 * scale; + }; + if (nearEq(nx, m_lastX) && nearEq(ny, m_lastY)) return; m_lastX = nx; m_lastY = ny; emit valueChanged(nx, ny); diff --git a/client/gui/props/EntityPropertySection.cpp b/client/gui/props/EntityPropertySection.cpp index 70e315b..4caaafa 100644 --- a/client/gui/props/EntityPropertySection.cpp +++ b/client/gui/props/EntityPropertySection.cpp @@ -4,8 +4,14 @@ #include #include +#include #include #include +#include +#include +#include +#include +#include #include namespace gui { @@ -50,8 +56,75 @@ EntityPropertySection::EntityPropertySection(QWidget* parent) form->addRow(QStringLiteral("整体缩放"), m_userScale); lay->addLayout(form); + + m_introHeader = new QWidget(this); + auto* headLay = new QHBoxLayout(m_introHeader); + headLay->setContentsMargins(0, 2, 0, 2); + headLay->setSpacing(6); + m_introToggle = new QToolButton(m_introHeader); + m_introToggle->setAutoRaise(true); + m_introToggle->setToolButtonStyle(Qt::ToolButtonTextOnly); + m_introToggle->setCursor(Qt::PointingHandCursor); + m_introToggle->setToolTip(QStringLiteral("展开或折叠介绍设置")); + m_introToggle->setText(QStringLiteral("▶")); + m_introToggle->setFixedWidth(22); + auto* introTitleLab = new QLabel(QStringLiteral("介绍"), m_introHeader); + introTitleLab->setStyleSheet(QStringLiteral("QLabel { font-weight: 600; }")); + headLay->addWidget(m_introToggle, 0, Qt::AlignVCenter); + headLay->addWidget(introTitleLab, 1, Qt::AlignVCenter); + + m_introContent = new QWidget(this); + auto* introLay = new QVBoxLayout(m_introContent); + introLay->setContentsMargins(8, 4, 0, 0); + introLay->setSpacing(6); + + m_introTitle = new QLineEdit(m_introContent); + m_introTitle->setPlaceholderText(QStringLiteral("标题")); + introLay->addWidget(new QLabel(QStringLiteral("标题"), m_introContent)); + introLay->addWidget(m_introTitle); + + m_introBody = new QTextEdit(m_introContent); + m_introBody->setPlaceholderText(QStringLiteral("正文(纯文本)")); + m_introBody->setMaximumHeight(100); + introLay->addWidget(new QLabel(QStringLiteral("正文"), m_introContent)); + introLay->addWidget(m_introBody); + + m_introImages = new QListWidget(m_introContent); + m_introImages->setMinimumHeight(72); + m_introImages->setToolTip(QStringLiteral("配图相对路径列表;使用下方按钮从文件添加")); + introLay->addWidget(new QLabel(QStringLiteral("配图"), m_introContent)); + introLay->addWidget(m_introImages); + + auto* imgRow = new QHBoxLayout(); + m_btnIntroAddImage = new QPushButton(QStringLiteral("添加配图…"), m_introContent); + m_btnIntroRemoveImage = new QPushButton(QStringLiteral("移除选中"), m_introContent); + imgRow->addWidget(m_btnIntroAddImage); + imgRow->addWidget(m_btnIntroRemoveImage); + introLay->addLayout(imgRow); + + m_introVideo = new QLineEdit(m_introContent); + m_introVideo->setPlaceholderText(QStringLiteral("可选:视频相对路径(如 assets/entities/xxx.mp4)")); + introLay->addWidget(new QLabel(QStringLiteral("视频路径(预留)"), m_introContent)); + introLay->addWidget(m_introVideo); + + lay->addWidget(m_introHeader); + lay->addWidget(m_introContent); lay->addStretch(1); + m_introContent->setVisible(false); + + m_introSaveTimer = new QTimer(this); + m_introSaveTimer->setSingleShot(true); + connect(m_introSaveTimer, &QTimer::timeout, this, [this]() { + if (!m_introBulkUpdate) { + emit introContentEdited(); + } + }); + + connect(m_introToggle, &QToolButton::clicked, this, [this]() { + setIntroSectionExpanded(!m_introContent->isVisible()); + }); + connect(m_name, &QLineEdit::editingFinished, this, [this]() { if (m_name) { emit displayNameCommitted(m_name->text()); @@ -60,9 +133,41 @@ EntityPropertySection::EntityPropertySection(QWidget* parent) connect(m_pivot, &Vec2ParamControl::valueChanged, this, &EntityPropertySection::pivotEdited); connect(m_centroid, &Vec2ParamControl::valueChanged, this, &EntityPropertySection::centroidEdited); connect(m_userScale, qOverload(&QDoubleSpinBox::valueChanged), this, &EntityPropertySection::userScaleEdited); + + connect(m_introTitle, &QLineEdit::textChanged, this, [this](const QString&) { scheduleIntroPersist(); }); + connect(m_introBody, &QTextEdit::textChanged, this, [this]() { scheduleIntroPersist(); }); + connect(m_introVideo, &QLineEdit::textChanged, this, [this](const QString&) { scheduleIntroPersist(); }); + + connect(m_btnIntroAddImage, &QPushButton::clicked, this, &EntityPropertySection::introAddImageRequested); + connect(m_btnIntroRemoveImage, &QPushButton::clicked, this, [this]() { + if (!m_introImages || !m_introImages->currentItem()) { + return; + } + delete m_introImages->takeItem(m_introImages->currentRow()); + scheduleIntroPersist(); + }); +} + +void EntityPropertySection::setIntroSectionExpanded(bool expanded) { + if (!m_introContent || !m_introToggle) { + return; + } + m_introContent->setVisible(expanded); + m_introToggle->setText(expanded ? QStringLiteral("▼") : QStringLiteral("▶")); +} + +void EntityPropertySection::scheduleIntroPersist() { + if (m_introBulkUpdate || !m_introSaveTimer) { + return; + } + m_introSaveTimer->start(400); } void EntityPropertySection::clearDisconnected() { + if (m_introSaveTimer) { + m_introSaveTimer->stop(); + } + m_introBulkUpdate = true; setEditingEnabled(false); if (m_name) { m_name->blockSignals(true); @@ -78,9 +183,32 @@ void EntityPropertySection::clearDisconnected() { m_userScale->setValue(1.0); m_userScale->blockSignals(false); } + if (m_introTitle) { + m_introTitle->blockSignals(true); + m_introTitle->clear(); + m_introTitle->blockSignals(false); + } + if (m_introBody) { + m_introBody->blockSignals(true); + m_introBody->clear(); + m_introBody->blockSignals(false); + } + if (m_introVideo) { + m_introVideo->blockSignals(true); + m_introVideo->clear(); + m_introVideo->blockSignals(false); + } + if (m_introImages) m_introImages->clear(); + setIntroSectionExpanded(false); + m_introBulkUpdate = false; } void EntityPropertySection::applyState(const EntityPropertyUiState& s) { + if (m_introSaveTimer) { + m_introSaveTimer->stop(); + } + m_introBulkUpdate = true; + setEditingEnabled(true); if (m_name) { m_name->blockSignals(true); @@ -96,6 +224,63 @@ void EntityPropertySection::applyState(const EntityPropertyUiState& s) { m_userScale->setValue(s.userScale); m_userScale->blockSignals(false); } + if (m_introTitle) { + m_introTitle->blockSignals(true); + m_introTitle->setText(s.intro.title); + m_introTitle->blockSignals(false); + } + if (m_introBody) { + m_introBody->blockSignals(true); + m_introBody->setPlainText(s.intro.bodyText); + m_introBody->blockSignals(false); + } + if (m_introVideo) { + m_introVideo->blockSignals(true); + m_introVideo->setText(s.intro.videoPathRelative); + m_introVideo->blockSignals(false); + } + if (m_introImages) { + m_introImages->clear(); + for (const QString& p : s.intro.imagePathsRelative) { + if (!p.isEmpty()) { + auto* it = new QListWidgetItem(p, m_introImages); + it->setData(Qt::UserRole, p); + } + } + } + setIntroSectionExpanded(false); + m_introBulkUpdate = false; +} + +void EntityPropertySection::appendIntroImagePath(const QString& relativePath) { + if (!m_introImages || relativePath.isEmpty()) { + return; + } + auto* it = new QListWidgetItem(relativePath, m_introImages); + it->setData(Qt::UserRole, relativePath); + scheduleIntroPersist(); +} + +core::EntityIntroContent EntityPropertySection::introSnapshot() const { + core::EntityIntroContent out; + if (m_introTitle) { + out.title = m_introTitle->text(); + } + if (m_introBody) { + out.bodyText = m_introBody->toPlainText(); + } + if (m_introVideo) { + out.videoPathRelative = m_introVideo->text().trimmed(); + } + if (m_introImages) { + for (int i = 0; i < m_introImages->count(); ++i) { + const QString p = m_introImages->item(i)->data(Qt::UserRole).toString(); + if (!p.isEmpty()) { + out.imagePathsRelative.push_back(p); + } + } + } + return out; } void EntityPropertySection::setEditingEnabled(bool on) { @@ -103,6 +288,14 @@ void EntityPropertySection::setEditingEnabled(bool on) { if (m_pivot) m_pivot->setEnabled(on); if (m_centroid) m_centroid->setEnabled(on); if (m_userScale) m_userScale->setEnabled(on); + if (m_introHeader) m_introHeader->setEnabled(on); + if (m_introToggle) m_introToggle->setEnabled(on); + if (m_introTitle) m_introTitle->setEnabled(on); + if (m_introBody) m_introBody->setEnabled(on); + if (m_introVideo) m_introVideo->setEnabled(on); + if (m_introImages) m_introImages->setEnabled(on); + if (m_btnIntroAddImage) m_btnIntroAddImage->setEnabled(on); + if (m_btnIntroRemoveImage) m_btnIntroRemoveImage->setEnabled(on); } } // namespace gui diff --git a/client/gui/props/EntityPropertySection.h b/client/gui/props/EntityPropertySection.h index fad8b68..cefdb1f 100644 --- a/client/gui/props/EntityPropertySection.h +++ b/client/gui/props/EntityPropertySection.h @@ -1,5 +1,6 @@ #pragma once +#include "core/domain/EntityIntro.h" #include "props/PropertySectionWidget.h" #include @@ -8,6 +9,12 @@ class QLabel; class QLineEdit; class QDoubleSpinBox; +class QTextEdit; +class QListWidget; +class QPushButton; +class QToolButton; +class QTimer; +class QWidget; namespace gui { class Vec2ParamControl; @@ -22,6 +29,7 @@ struct EntityPropertyUiState { QPointF pivot; QPointF centroid; double userScale = 1.0; + core::EntityIntroContent intro; }; // 实体相关属性(可嵌入 QStackedWidget 的一页) @@ -33,20 +41,41 @@ public: void clearDisconnected(); void applyState(const EntityPropertyUiState& s); void setEditingEnabled(bool on); + [[nodiscard]] core::EntityIntroContent introSnapshot() const; + void appendIntroImagePath(const QString& relativePath); signals: void displayNameCommitted(const QString& text); void pivotEdited(double x, double y); void centroidEdited(double x, double y); void userScaleEdited(double value); + /// 介绍字段变更后防抖触发,由主窗口写入工程 + void introContentEdited(); + void introAddImageRequested(); private: + void scheduleIntroPersist(); + void setIntroSectionExpanded(bool expanded); + QLineEdit* m_name = nullptr; QLabel* m_depth = nullptr; QLabel* m_distScale = nullptr; Vec2ParamControl* m_pivot = nullptr; Vec2ParamControl* m_centroid = nullptr; QDoubleSpinBox* m_userScale = nullptr; + + QLineEdit* m_introTitle = nullptr; + QTextEdit* m_introBody = nullptr; + QLineEdit* m_introVideo = nullptr; + QListWidget* m_introImages = nullptr; + QPushButton* m_btnIntroAddImage = nullptr; + QPushButton* m_btnIntroRemoveImage = nullptr; + + QWidget* m_introHeader = nullptr; + QToolButton* m_introToggle = nullptr; + QWidget* m_introContent = nullptr; + QTimer* m_introSaveTimer = nullptr; + bool m_introBulkUpdate = false; }; } // namespace gui