导出数据转spine
This commit is contained in:
@@ -0,0 +1,437 @@
|
||||
#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
|
||||
Reference in New Issue
Block a user