Files
SpineParticlesWeb/DateToSpine/src/ui/MainWindow.cpp
T
2026-09-07 21:29:15 +08:00

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