diff --git a/DateToSpine/CMakeLists.txt b/DateToSpine/CMakeLists.txt index 4909717..1fa7e40 100644 --- a/DateToSpine/CMakeLists.txt +++ b/DateToSpine/CMakeLists.txt @@ -76,19 +76,27 @@ if(DTS_BUILD_GUI) if(DTS_BUILD_RUNTIME_PLUGINS) add_dependencies(DateToSpine dts_runtime_38 dts_runtime_40 dts_runtime_41 dts_runtime_42 dts_runtime_43) + # Runtime plugin destination differs by platform: + # - macOS: inside the .app bundle (Contents/PlugIns/runtimes) + # - Windows/other: alongside the executable + if(APPLE) + set(DTS_RUNTIME_PLUGIN_DIR "$/Contents/PlugIns/runtimes") + else() + set(DTS_RUNTIME_PLUGIN_DIR "$/runtimes") + endif() foreach(runtime_line IN ITEMS 38 40 41 42 43) add_custom_command(TARGET dts_runtime_${runtime_line} POST_BUILD COMMAND ${CMAKE_COMMAND} -E make_directory - "$/Contents/PlugIns/runtimes" + "${DTS_RUNTIME_PLUGIN_DIR}" COMMAND ${CMAKE_COMMAND} -E copy_if_different "$" - "$/Contents/PlugIns/runtimes/") + "${DTS_RUNTIME_PLUGIN_DIR}/") add_custom_command(TARGET DateToSpine POST_BUILD COMMAND ${CMAKE_COMMAND} -E make_directory - "$/Contents/PlugIns/runtimes" + "${DTS_RUNTIME_PLUGIN_DIR}" COMMAND ${CMAKE_COMMAND} -E copy_if_different "$" - "$/Contents/PlugIns/runtimes/") + "${DTS_RUNTIME_PLUGIN_DIR}/") endforeach() endif() endif() diff --git a/DateToSpine/src/editor/SpineOutputParser.cpp b/DateToSpine/src/editor/SpineOutputParser.cpp index dd25a24..ef2cefd 100644 --- a/DateToSpine/src/editor/SpineOutputParser.cpp +++ b/DateToSpine/src/editor/SpineOutputParser.cpp @@ -39,9 +39,11 @@ EditorProbeResult SpineOutputParser::parse( static const std::regex launcher(R"(Spine Launcher\s+(\d+\.\d+(?:\.\d+)?))"); static const std::regex launching( R"((?:Launching:|Starting:)\s*Spine\s+(\d+\.\d+(?:\.\d+)?)(?:\s+([^\r\n]+))?)"); + // MSVC's std::regex does not implement std::regex_constants::multiline, + // so instead of relying on the '^' line anchor with the multiline flag, + // search for "Spine " anywhere (regex_search scans the whole text). static const std::regex directEditor( - R"(^Spine\s+(\d+\.\d+(?:\.\d+)?)(?:\s+([^\r\n]+))?)", - std::regex_constants::multiline); + R"(Spine\s+(\d+\.\d+(?:\.\d+)?)(?:\s+([^\r\n]+))?)"); result.launcherVersion = firstVersion(process.output, launcher); result.editorVersion = firstVersion(process.output, launching, &result.edition); diff --git a/DateToSpine/src/editor/SpineVersionAvailability.cpp b/DateToSpine/src/editor/SpineVersionAvailability.cpp index 4ed8c12..063c331 100644 --- a/DateToSpine/src/editor/SpineVersionAvailability.cpp +++ b/DateToSpine/src/editor/SpineVersionAvailability.cpp @@ -17,6 +17,10 @@ std::vector SpineVersionAvailability::defaultCacheRoots() const { roots.emplace_back(fs::path(roaming) / "Spine/updates"); if (const char* local = std::getenv("LOCALAPPDATA")) roots.emplace_back(fs::path(local) / "Spine/updates"); + // Spine Launcher also keeps its update cache directly under the user's + // profile directory (e.g. C:\Users\\Spine\updates). + if (const char* userProfile = std::getenv("USERPROFILE")) + roots.emplace_back(fs::path(userProfile) / "Spine/updates"); #endif return roots; } diff --git a/DateToSpine/src/ui/MainWindow.cpp b/DateToSpine/src/ui/MainWindow.cpp index 7b59ff6..7b5f74d 100644 --- a/DateToSpine/src/ui/MainWindow.cpp +++ b/DateToSpine/src/ui/MainWindow.cpp @@ -3,7 +3,9 @@ #include "atlas/AtlasParser.hpp" #include "conversion/ConversionJob.hpp" #include "editor/SpineEditorLocator.hpp" +#include "editor/SpineProbe.hpp" #include "editor/SpineVersionAvailability.hpp" +#include "platform/ProcessRunner.hpp" #include "scan/SpineDataVersionDetector.hpp" #include "ui/SpinePreviewWidget.hpp" @@ -23,6 +25,7 @@ #include #include #include +#include #include #include @@ -134,38 +137,69 @@ void MainWindow::setupUi() { connect(convertButton_, &QPushButton::clicked, this, &MainWindow::convertCurrent); previewTimer_ = new QTimer(this); previewTimer_->setInterval(16); + previewTimer_->setTimerType(Qt::PreciseTimer); connect(previewTimer_, &QTimer::timeout, this, &MainWindow::advancePreview); + + editorProbeWatcher_ = new QFutureWatcher>(this); + connect(editorProbeWatcher_, + &QFutureWatcher>::finished, + this, &MainWindow::onEditorProbeFinished); } void MainWindow::showEmptyState() { previewWidget_->resetViewport(); previewWidget_->setMessage( - tr("把 .json / .skel、.atlas 和图片拖到这里\n\n也可以拖入整个资源文件夹")); + tr("把 .json / .skel / .skel.bytes、.atlas / .atlas.txt 和图片拖到这里\n\n也可以拖入整个资源文件夹")); statusLabel_->clear(); } void MainWindow::chooseResources() { const auto paths = QFileDialog::getOpenFileNames(this, tr("选择导出资源"), {}, - tr("Spine 资源 (*.json *.skel *.atlas *.png *.jpg *.jpeg *.webp);;所有文件 (*)")); + tr("Spine 资源 (*.json *.skel *.skel.bytes *.atlas *.atlas.txt *.png *.jpg *.jpeg *.webp);;所有文件 (*)")); for (const auto& path : paths) addInput(path); } void MainWindow::addInput(const QString& path) { const QFileInfo info(path); - const auto scanDirectory = [this](const QString& directory, bool recursive) { - QDirIterator iterator(directory, {"*.json", "*.skel"}, QDir::Files, + const auto scanDirectory = [this](const QString& directory, bool recursive, bool warnUnsupported) { + QDirIterator iterator(directory, {"*.json", "*.skel", "*.skel.bytes"}, QDir::Files, recursive ? QDirIterator::Subdirectories : QDirIterator::NoIteratorFlags); - while (iterator.hasNext()) addSkeleton(iterator.next()); + while (iterator.hasNext()) addSkeleton(iterator.next(), warnUnsupported); }; - if (info.isDir()) scanDirectory(path, true); + // Only the primary skeleton data (.json / .skel / .skel.bytes) carries a + // Spine version and should trigger the "unsupported version" warning. + // Companion files (.atlas / .atlas.txt / images) have no version of their + // own, so they must not cause a version check when scanned for a skeleton. + if (info.isDir()) scanDirectory(path, true, true); else if (info.suffix().compare("json", Qt::CaseInsensitive) == 0 - || info.suffix().compare("skel", Qt::CaseInsensitive) == 0) addSkeleton(path); - else scanDirectory(info.absolutePath(), false); + || info.suffix().compare("skel", Qt::CaseInsensitive) == 0 + || info.completeSuffix().compare("skel.bytes", Qt::CaseInsensitive) == 0) addSkeleton(path, true); + else scanDirectory(info.absolutePath(), false, false); scheduleAvailabilityCheck(); } -void MainWindow::addSkeleton(const QString& path) { +void MainWindow::addSkeleton(const QString& path, bool warnInvalid) { const auto result = SpineDataVersionDetector{}.inspect(localPath(path)); + if (result.status == VersionDetectionStatus::UnsupportedVersion) { + if (warnInvalid) { + const auto version = result.version + ? QString::fromStdString(result.version->toString()) : tr("未知"); + pendingVersionWarnings_.append( + tr("%1(版本 %2)").arg(QFileInfo(path).fileName(), version)); + // Defer to the event loop so multiple unsupported files in a single + // import are collected and reported with one dialog. + QTimer::singleShot(0, this, &MainWindow::showUnsupportedVersionWarning); + } + return; + } + if (result.status == VersionDetectionStatus::InvalidData + || result.status == VersionDetectionStatus::FileMissing) { + if (warnInvalid) { + pendingInvalidAssets_.append(QFileInfo(path).fileName()); + QTimer::singleShot(0, this, &MainWindow::showInvalidAssetWarning); + } + return; + } if (result.status != VersionDetectionStatus::Detected || skeletonPaths_.contains(path)) return; skeletonPaths_.append(path); resourceCombo_->addItem(QFileInfo(path).completeBaseName(), path); @@ -173,6 +207,32 @@ void MainWindow::addSkeleton(const QString& path) { resourceCombo_->setCurrentIndex(resourceCombo_->count() - 1); } +void MainWindow::showUnsupportedVersionWarning() { + if (pendingVersionWarnings_.isEmpty()) return; + // Deduplicate so the same resource reported multiple times (e.g. repeated + // drag-and-drop events) is only listed once. + QStringList unique; + for (const auto& item : pendingVersionWarnings_) + if (!unique.contains(item)) unique.append(item); + pendingVersionWarnings_.clear(); + const auto details = unique.join(QLatin1String("\n")); + QMessageBox::warning(this, tr("版本过低"), + tr("以下资源的 Spine 版本不在支持的范围内(支持的版本:3.8、4.0、4.1、4.2、4.3):\n\n%1") + .arg(details)); +} + +void MainWindow::showInvalidAssetWarning() { + if (pendingInvalidAssets_.isEmpty()) return; + // Deduplicate so the same file reported multiple times is only listed once. + QStringList unique; + for (const auto& item : pendingInvalidAssets_) + if (!unique.contains(item)) unique.append(item); + pendingInvalidAssets_.clear(); + const auto details = unique.join(QLatin1String("\n")); + QMessageBox::warning(this, tr("未识别到正确的 Spine 资产"), + tr("以下文件未能识别为有效的 Spine 资产:\n\n%1").arg(details)); +} + QString MainWindow::findAtlas(const QString& skeletonPath) const { const QFileInfo skeleton(skeletonPath); QDir directory(skeleton.absolutePath()); @@ -256,13 +316,18 @@ void MainWindow::animationChanged(const QString& name) { statusLabel_->setText(QString::fromStdString(error)); return; } + previewClock_.restart(); advancePreview(); previewTimer_->start(); } void MainWindow::advancePreview() { std::string error; - auto frame = runtime_.updatePreview(0.016F, error); + // Use the real elapsed wall-clock time as the frame delta so the animation + // matches the actual playback speed even if the timer interval drifts + // (Windows timers are not exact). Falls back to 16ms on the first frame. + const float delta = previewClock_.isValid() ? previewClock_.restart() / 1000.0F : 0.016F; + auto frame = runtime_.updatePreview(delta, error); if (!frame) { previewTimer_->stop(); previewWidget_->setMessage(tr("动画预览暂时不可用")); @@ -351,9 +416,13 @@ void MainWindow::refreshAvailability() { } currentEditorVersion_ = availableVersions_.value(line); if (currentEditorVersion_.isEmpty()) { - showAvailability(tr("缺少 Spine %1").arg(line), QStringLiteral("#ef6b73")); - convertButton_->setText(tr("指定 Spine %1 路径").arg(line)); - convertButton_->setEnabled(true); + // Run the version check (cache scan + editor probe) in the background + // (bounded by the probe timeout) so the UI does not block. If no + // matching version is found, the user is warned with a dialog. + showAvailability(tr("正在检查 Editor ..."), QStringLiteral("#f2b84b")); + convertButton_->setText(tr("检查中...")); + convertButton_->setEnabled(false); + startEditorProbe(line); return; } showAvailability(tr("可转换 · Spine %1").arg(currentEditorVersion_), @@ -362,6 +431,47 @@ void MainWindow::refreshAvailability() { convertButton_->setEnabled(true); } +void MainWindow::startEditorProbe(const QString& line) { + const std::string lineStr = line.toStdString(); + const auto editorPath = localPath(editorPath_); + const int generation = ++editorProbeGeneration_; + auto future = QtConcurrent::run([lineStr, editorPath, generation]() -> QPair { + // Scan the Spine update cache first; if no matching release is cached, + // probe the explicitly selected Editor executable (bounded timeout). + SpineVersionAvailability availability; + if (const auto cached = availability.newestForLine(lineStr)) + return {generation, QString::fromStdString(cached->toString())}; + const auto runner = createDefaultProcessRunner(); + const SpineProbe probe(*runner); + const auto result = probe.inspect(editorPath, std::chrono::seconds(3)); + if (result.editorVersion && result.editorVersion->line() == lineStr) + return {generation, QString::fromStdString(result.editorVersion->toString())}; + return {generation, QString{}}; + }); + editorProbeWatcher_->setFuture(future); +} + +void MainWindow::onEditorProbeFinished() { + if (!editorProbeWatcher_ || !editorProbeWatcher_->isFinished()) return; + const auto outcome = editorProbeWatcher_->result(); + if (outcome.first != editorProbeGeneration_) return; + const QString version = outcome.second; + if (version.isEmpty()) { + showAvailability(tr("未找到对应版本"), QStringLiteral("#ef6b73")); + convertButton_->setText(tr("指定 Spine 路径")); + convertButton_->setEnabled(true); + // Warn the user one second after the scan when no matching version is found. + QTimer::singleShot(1000, this, [this] { + QMessageBox::warning(this, tr("未找到对应版本"), tr("未找到对应版本")); + }); + } else { + currentEditorVersion_ = version; + showAvailability(tr("可转换 · Spine %1").arg(version), QStringLiteral("#55d68b")); + convertButton_->setText(tr("一键转换为 .spine")); + convertButton_->setEnabled(true); + } +} + bool MainWindow::chooseEditorPath() { const auto selected = QFileDialog::getOpenFileName(this, tr("指定 Spine 程序"), {}, tr("所有文件 (*)")); diff --git a/DateToSpine/src/ui/MainWindow.hpp b/DateToSpine/src/ui/MainWindow.hpp index e86159d..95d43a1 100644 --- a/DateToSpine/src/ui/MainWindow.hpp +++ b/DateToSpine/src/ui/MainWindow.hpp @@ -5,6 +5,9 @@ #include #include #include +#include +#include +#include class QComboBox; class QCheckBox; @@ -36,11 +39,12 @@ private slots: const QString& error); void animationChanged(const QString& name); void advancePreview(); + void onEditorProbeFinished(); private: void setupUi(); void addInput(const QString& path); - void addSkeleton(const QString& path); + void addSkeleton(const QString& path, bool warnInvalid = true); void loadCurrentPreview(); QString findAtlas(const QString& skeletonPath) const; QString runtimeLibrary(const QString& versionLine) const; @@ -51,6 +55,9 @@ private: void showAvailability(const QString& text, const QString& color); void showEmptyState(); void setBusy(bool busy); + void startEditorProbe(const QString& line); + void showUnsupportedVersionWarning(); + void showInvalidAssetWarning(); QComboBox* resourceCombo_ = nullptr; QComboBox* animationCombo_ = nullptr; @@ -68,6 +75,11 @@ private: RuntimePlugin runtime_; ConversionJob* conversionJob_ = nullptr; QTimer* previewTimer_ = nullptr; + QElapsedTimer previewClock_; + QFutureWatcher>* editorProbeWatcher_ = nullptr; + int editorProbeGeneration_ = 0; + QStringList pendingVersionWarnings_; + QStringList pendingInvalidAssets_; }; } // namespace dts