新增windows支持

This commit is contained in:
2026-09-07 23:48:20 +08:00
parent 6837ecada9
commit bd0771747d
5 changed files with 156 additions and 20 deletions
+12 -4
View File
@@ -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 "$<TARGET_BUNDLE_DIR:DateToSpine>/Contents/PlugIns/runtimes")
else()
set(DTS_RUNTIME_PLUGIN_DIR "$<TARGET_FILE_DIR:DateToSpine>/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
"$<TARGET_BUNDLE_DIR:DateToSpine>/Contents/PlugIns/runtimes"
"${DTS_RUNTIME_PLUGIN_DIR}"
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"$<TARGET_FILE:dts_runtime_${runtime_line}>"
"$<TARGET_BUNDLE_DIR:DateToSpine>/Contents/PlugIns/runtimes/")
"${DTS_RUNTIME_PLUGIN_DIR}/")
add_custom_command(TARGET DateToSpine POST_BUILD
COMMAND ${CMAKE_COMMAND} -E make_directory
"$<TARGET_BUNDLE_DIR:DateToSpine>/Contents/PlugIns/runtimes"
"${DTS_RUNTIME_PLUGIN_DIR}"
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"$<TARGET_FILE:dts_runtime_${runtime_line}>"
"$<TARGET_BUNDLE_DIR:DateToSpine>/Contents/PlugIns/runtimes/")
"${DTS_RUNTIME_PLUGIN_DIR}/")
endforeach()
endif()
endif()
+4 -2
View File
@@ -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 <version>" 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);
@@ -17,6 +17,10 @@ std::vector<fs::path> 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\<name>\Spine\updates).
if (const char* userProfile = std::getenv("USERPROFILE"))
roots.emplace_back(fs::path(userProfile) / "Spine/updates");
#endif
return roots;
}
+123 -13
View File
@@ -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 <QPushButton>
#include <QSettings>
#include <QTimer>
#include <QtConcurrent/QtConcurrent>
#include <QUrl>
#include <QVBoxLayout>
@@ -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<QPair<int, QString>>(this);
connect(editorProbeWatcher_,
&QFutureWatcher<QPair<int, QString>>::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<int, QString> {
// 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("所有文件 (*)"));
+13 -1
View File
@@ -5,6 +5,9 @@
#include <QMainWindow>
#include <QHash>
#include <QStringList>
#include <QElapsedTimer>
#include <QFutureWatcher>
#include <QPair>
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<QPair<int, QString>>* editorProbeWatcher_ = nullptr;
int editorProbeGeneration_ = 0;
QStringList pendingVersionWarnings_;
QStringList pendingInvalidAssets_;
};
} // namespace dts