438 lines
17 KiB
C++
438 lines
17 KiB
C++
#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 <QComboBox>
|
|
#include <QCheckBox>
|
|
#include <QCoreApplication>
|
|
#include <QDir>
|
|
#include <QDirIterator>
|
|
#include <QDragEnterEvent>
|
|
#include <QDropEvent>
|
|
#include <QFileDialog>
|
|
#include <QFileInfo>
|
|
#include <QHBoxLayout>
|
|
#include <QLabel>
|
|
#include <QMimeData>
|
|
#include <QMessageBox>
|
|
#include <QPushButton>
|
|
#include <QSettings>
|
|
#include <QTimer>
|
|
#include <QUrl>
|
|
#include <QVBoxLayout>
|
|
|
|
#include <filesystem>
|
|
|
|
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
|