#include "ui/MainWindow.hpp" #include "atlas/AtlasParser.hpp" #include "conversion/ConversionJob.hpp" #include "editor/SpineEditorLocator.hpp" #include "editor/SpineVersionAvailability.hpp" #include "scan/SpineDataVersionDetector.hpp" #include "ui/SpinePreviewWidget.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace dts { namespace { std::filesystem::path localPath(const QString& value) { #if defined(_WIN32) return std::filesystem::path(value.toStdWString()); #else return std::filesystem::path(value.toStdString()); #endif } QString platformLibraryName(const QString& line) { #if defined(_WIN32) return QStringLiteral("dts-runtime-%1.dll").arg(line); #elif defined(__APPLE__) return QStringLiteral("libdts-runtime-%1.dylib").arg(line); #else return QStringLiteral("libdts-runtime-%1.so").arg(line); #endif } } // namespace MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) { setupUi(); setAcceptDrops(true); setWindowTitle(tr("DateToSpine")); resize(960, 680); } void MainWindow::openResourcePath(const QString& path) { addInput(path); } void MainWindow::setupUi() { auto* root = new QWidget; auto* layout = new QVBoxLayout(root); layout->setContentsMargins(28, 24, 28, 24); layout->setSpacing(16); auto* top = new QHBoxLayout; auto* title = new QLabel(tr("DateToSpine")); title->setObjectName(QStringLiteral("title")); availabilityLabel_ = new QLabel; availabilityLabel_->setAlignment(Qt::AlignCenter); availabilityLabel_->setMinimumWidth(190); availabilityLabel_->setVisible(false); resourceCombo_ = new QComboBox; resourceCombo_->setMinimumWidth(260); resourceCombo_->setVisible(false); auto* addButton = new QPushButton(tr("选择资源")); addButton->setObjectName(QStringLiteral("quietButton")); top->addWidget(title); top->addStretch(); top->addWidget(availabilityLabel_); top->addStretch(); top->addWidget(resourceCombo_); top->addWidget(addButton); layout->addLayout(top); previewWidget_ = new SpinePreviewWidget; previewWidget_->setObjectName(QStringLiteral("preview")); layout->addWidget(previewWidget_, 1); auto* controls = new QHBoxLayout; animationCombo_ = new QComboBox; animationCombo_->setMinimumWidth(220); animationCombo_->setEnabled(false); statusLabel_ = new QLabel; statusLabel_->setObjectName(QStringLiteral("status")); unpremultiplyCheck_ = new QCheckBox(tr("去除预乘 Alpha")); unpremultiplyCheck_->setToolTip(tr("由 Spine 官方纹理解包器转换为普通透明图片")); convertButton_ = new QPushButton(tr("一键转换为 .spine")); convertButton_->setObjectName(QStringLiteral("primaryButton")); convertButton_->setEnabled(false); controls->addWidget(new QLabel(tr("动画"))); controls->addWidget(animationCombo_); controls->addWidget(statusLabel_, 1); controls->addWidget(unpremultiplyCheck_); controls->addWidget(convertButton_); layout->addLayout(controls); setCentralWidget(root); setStyleSheet(QStringLiteral(R"( QMainWindow, QWidget { background: #17191e; color: #f3f4f6; font-size: 14px; } QLabel#title { font-size: 22px; font-weight: 650; } QLabel#preview { background: #101217; border: 2px dashed #343944; border-radius: 14px; color: #a7adb8; } QLabel#status { color: #9299a5; padding-left: 8px; } QComboBox { background: #242832; border: 1px solid #3a404c; border-radius: 8px; padding: 8px 12px; } QPushButton { border-radius: 8px; padding: 9px 16px; } QPushButton#quietButton { background: #292d36; border: 1px solid #3d4350; } QPushButton#primaryButton { background: #6e5eea; border: none; font-weight: 650; padding: 11px 20px; } QPushButton#primaryButton:disabled { background: #343743; color: #777d89; } QCheckBox { spacing: 8px; color: #c9ced8; } QCheckBox::indicator { width: 18px; height: 18px; } )")); showEmptyState(); connect(addButton, &QPushButton::clicked, this, &MainWindow::chooseResources); connect(resourceCombo_, &QComboBox::currentIndexChanged, this, &MainWindow::resourceChanged); connect(animationCombo_, &QComboBox::currentTextChanged, this, &MainWindow::animationChanged); connect(convertButton_, &QPushButton::clicked, this, &MainWindow::convertCurrent); previewTimer_ = new QTimer(this); previewTimer_->setInterval(16); connect(previewTimer_, &QTimer::timeout, this, &MainWindow::advancePreview); } void MainWindow::showEmptyState() { previewWidget_->resetViewport(); previewWidget_->setMessage( tr("把 .json / .skel、.atlas 和图片拖到这里\n\n也可以拖入整个资源文件夹")); statusLabel_->clear(); } void MainWindow::chooseResources() { const auto paths = QFileDialog::getOpenFileNames(this, tr("选择导出资源"), {}, tr("Spine 资源 (*.json *.skel *.atlas *.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, recursive ? QDirIterator::Subdirectories : QDirIterator::NoIteratorFlags); while (iterator.hasNext()) addSkeleton(iterator.next()); }; if (info.isDir()) scanDirectory(path, true); else if (info.suffix().compare("json", Qt::CaseInsensitive) == 0 || info.suffix().compare("skel", Qt::CaseInsensitive) == 0) addSkeleton(path); else scanDirectory(info.absolutePath(), false); scheduleAvailabilityCheck(); } void MainWindow::addSkeleton(const QString& path) { const auto result = SpineDataVersionDetector{}.inspect(localPath(path)); if (result.status != VersionDetectionStatus::Detected || skeletonPaths_.contains(path)) return; skeletonPaths_.append(path); resourceCombo_->addItem(QFileInfo(path).completeBaseName(), path); resourceCombo_->setVisible(true); resourceCombo_->setCurrentIndex(resourceCombo_->count() - 1); } QString MainWindow::findAtlas(const QString& skeletonPath) const { const QFileInfo skeleton(skeletonPath); QDir directory(skeleton.absolutePath()); const QString base = skeleton.completeBaseName(); for (const auto& name : {base + ".atlas", base + ".atlas.txt"}) { const auto candidate = directory.filePath(name); if (QFileInfo::exists(candidate)) return candidate; } const auto atlases = directory.entryInfoList({"*.atlas", "*.atlas.txt"}, QDir::Files); if (atlases.size() == 1) return atlases.front().absoluteFilePath(); for (const auto& atlas : atlases) { if (base.startsWith(atlas.completeBaseName()) || atlas.completeBaseName().startsWith(base)) return atlas.absoluteFilePath(); } return {}; } QString MainWindow::runtimeLibrary(const QString& versionLine) const { const QDir executableDirectory(QCoreApplication::applicationDirPath()); #if defined(__APPLE__) return QDir::cleanPath(executableDirectory.filePath( "../PlugIns/runtimes/" + platformLibraryName(versionLine))); #else return executableDirectory.filePath("runtimes/" + platformLibraryName(versionLine)); #endif } void MainWindow::resourceChanged(int index) { if (index >= 0) { loadCurrentPreview(); scheduleAvailabilityCheck(); } } void MainWindow::loadCurrentPreview() { previewTimer_->stop(); previewWidget_->resetViewport(); animationCombo_->clear(); animationCombo_->setEnabled(false); convertButton_->setEnabled(false); const QString skeletonPath = resourceCombo_->currentData().toString(); const QString atlasPath = findAtlas(skeletonPath); const auto detection = SpineDataVersionDetector{}.inspect(localPath(skeletonPath)); if (!detection.version || atlasPath.isEmpty()) { previewWidget_->setMessage(tr("没有找到与骨骼数据配套的 .atlas 文件")); statusLabel_->setText(tr("请把完整导出资源一起拖入")); return; } currentVersion_ = QString::fromStdString(detection.version->toString()); std::string error; if (!runtime_.load(localPath(runtimeLibrary( QString::fromStdString(detection.version->line()))), error)) { previewWidget_->setMessage(tr("暂时无法加载这套资源")); statusLabel_->setText(QString::fromStdString(error)); return; } const auto metadata = runtime_.loadSkeleton( localPath(skeletonPath), localPath(atlasPath), 1.0F, error); if (!metadata) { previewWidget_->setMessage(tr("此版本的预览模块仍在接入中")); statusLabel_->setText(tr("资源已识别,可以稍后转换")); return; } for (const auto& animation : metadata->animations) animationCombo_->addItem(QString::fromStdString(animation.name), animation.duration); animationCombo_->setEnabled(animationCombo_->count() > 0); statusLabel_->setText(tr("已识别 %1 个动画").arg(metadata->animations.size())); if (animationCombo_->count() > 0) { animationChanged(animationCombo_->itemText(0)); } else { auto frame = runtime_.updatePreview(0.0F, error); if (frame) previewWidget_->setFrame(std::move(*frame)); else previewWidget_->setMessage(tr("没有可播放的动画")); } } void MainWindow::animationChanged(const QString& name) { if (name.isEmpty()) return; std::string error; if (!runtime_.setAnimation(name.toStdString(), true, error)) { statusLabel_->setText(QString::fromStdString(error)); return; } advancePreview(); previewTimer_->start(); } void MainWindow::advancePreview() { std::string error; auto frame = runtime_.updatePreview(0.016F, error); if (!frame) { previewTimer_->stop(); previewWidget_->setMessage(tr("动画预览暂时不可用")); statusLabel_->setText(QString::fromStdString(error)); return; } previewWidget_->setFrame(std::move(*frame)); } QString MainWindow::resolveEditor() { QSettings settings; SpineEditorLocator locator; const auto saved = settings.value("spineEditorPath").toString(); if (!saved.isEmpty()) { if (const auto normalized = locator.normalize(localPath(saved))) return QString::fromStdString(normalized->string()); } const auto found = locator.discover(); if (!found.empty()) return QString::fromStdString(found.front().string()); return {}; } void MainWindow::scheduleAvailabilityCheck() { if (availabilityCheckScheduled_) return; availabilityCheckScheduled_ = true; QTimer::singleShot(0, this, [this] { availabilityCheckScheduled_ = false; refreshAvailability(); }); } void MainWindow::showAvailability(const QString& text, const QString& color) { availabilityLabel_->setText(QStringLiteral("● ") + text); availabilityLabel_->setStyleSheet(QStringLiteral( "QLabel { color: %1; background: #20242c; border: 1px solid #343a46; " "border-radius: 8px; padding: 7px 12px; font-weight: 600; }").arg(color)); availabilityLabel_->setVisible(true); } void MainWindow::refreshAvailability() { availableVersions_.clear(); currentEditorVersion_.clear(); editorPath_ = resolveEditor(); SpineVersionAvailability availability; for (const auto& path : skeletonPaths_) { const auto detection = SpineDataVersionDetector{}.inspect(localPath(path)); if (!detection.version) continue; const auto line = QString::fromStdString(detection.version->line()); if (const auto installed = availability.newestForLine(detection.version->line())) availableVersions_.insert(line, QString::fromStdString(installed->toString())); } const auto current = SpineDataVersionDetector{}.inspect( localPath(resourceCombo_->currentData().toString())); if (!current.version) return; const auto atlasPath = findAtlas(resourceCombo_->currentData().toString()); if (atlasPath.isEmpty()) { showAvailability(tr("缺少配套 Atlas"), QStringLiteral("#ef6b73")); convertButton_->setText(tr("资源不完整")); convertButton_->setEnabled(false); return; } const auto atlas = AtlasParser{}.parseFile(localPath(atlasPath)); if (!atlas.document) { showAvailability(tr("Atlas 无法识别"), QStringLiteral("#ef6b73")); convertButton_->setText(tr("资源不完整")); convertButton_->setEnabled(false); return; } const QDir atlasDirectory = QFileInfo(atlasPath).dir(); for (const auto& page : atlas.document->pages) { const auto pagePath = QString::fromStdString(page.imagePath.string()); if (!QFileInfo::exists(atlasDirectory.filePath(pagePath))) { showAvailability(tr("缺少纹理页 %1").arg(QFileInfo(pagePath).fileName()), QStringLiteral("#ef6b73")); convertButton_->setText(tr("资源不完整")); convertButton_->setEnabled(false); return; } } const auto line = QString::fromStdString(current.version->line()); if (editorPath_.isEmpty()) { showAvailability(tr("未找到 Spine Editor"), QStringLiteral("#f2b84b")); convertButton_->setText(tr("指定 Spine 路径")); convertButton_->setEnabled(true); return; } 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); return; } showAvailability(tr("可转换 · Spine %1").arg(currentEditorVersion_), QStringLiteral("#55d68b")); convertButton_->setText(tr("一键转换为 .spine")); convertButton_->setEnabled(true); } bool MainWindow::chooseEditorPath() { const auto selected = QFileDialog::getOpenFileName(this, tr("指定 Spine 程序"), {}, tr("所有文件 (*)")); if (selected.isEmpty()) return false; SpineEditorLocator locator; const auto normalized = locator.normalize(localPath(selected)); if (!normalized) { showAvailability(tr("选择的路径不是 Spine Editor"), QStringLiteral("#ef6b73")); return false; } QSettings{}.setValue("spineEditorPath", selected); editorPath_ = QString::fromStdString(normalized->string()); refreshAvailability(); return !currentEditorVersion_.isEmpty(); } void MainWindow::convertCurrent() { if (editorPath_.isEmpty() || currentEditorVersion_.isEmpty()) if (!chooseEditorPath()) return; const QString input = resourceCombo_->currentData().toString(); const auto outputParent = QFileDialog::getExistingDirectory(this, tr("选择输出位置"), QFileInfo(input).absolutePath()); if (outputParent.isEmpty()) return; const auto atlas = findAtlas(input); if (atlas.isEmpty()) { QMessageBox::critical(this, tr("转换失败"), tr("没有找到配套的 .atlas 文件。")); return; } ConversionRequest request{ editorPath_, currentEditorVersion_, input, atlas, outputParent, QFileInfo(input).completeBaseName(), unpremultiplyCheck_->isChecked()}; conversionJob_ = new ConversionJob(std::move(request), this); connect(conversionJob_, &ConversionJob::finished, this, &MainWindow::conversionFinished); setBusy(true); conversionJob_->start(); } void MainWindow::setBusy(bool busy) { convertButton_->setEnabled(!busy); convertButton_->setText(busy ? tr("正在转换…") : tr("一键转换为 .spine")); if (busy) showAvailability(tr("正在使用 Spine %1 转换").arg(currentEditorVersion_), QStringLiteral("#72a7ff")); } void MainWindow::conversionFinished(bool success, const QString& outputDirectory, const QString& error) { conversionJob_->deleteLater(); conversionJob_ = nullptr; setBusy(false); statusLabel_->setText(success ? tr("转换完成") : tr("转换失败")); statusLabel_->setToolTip(success ? outputDirectory : error); refreshAvailability(); if (success) { QMessageBox::information(this, tr("转换完成"), tr("完整资源已生成:\n%1").arg(outputDirectory)); } else { QMessageBox::critical(this, tr("转换失败"), error.isEmpty() ? tr("转换未完成,Spine Editor 未提供详细原因。") : error); } } void MainWindow::dragEnterEvent(QDragEnterEvent* event) { if (event->mimeData()->hasUrls()) event->acceptProposedAction(); } void MainWindow::dropEvent(QDropEvent* event) { for (const auto& url : event->mimeData()->urls()) if (url.isLocalFile()) addInput(url.toLocalFile()); event->acceptProposedAction(); } } // namespace dts