导出数据转spine
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
/build/
|
||||||
|
/.cache/
|
||||||
|
/tests/fixtures/external/
|
||||||
|
*.user
|
||||||
|
*.swp
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.25)
|
||||||
|
|
||||||
|
project(DateToSpine VERSION 0.1.0 LANGUAGES CXX)
|
||||||
|
|
||||||
|
option(DTS_BUILD_TESTS "Build DateToSpine tests" ON)
|
||||||
|
option(DTS_BUILD_GUI "Build the DateToSpine desktop application" ON)
|
||||||
|
option(DTS_BUILD_RUNTIME_PLUGINS "Build bundled Spine runtime adapters" ON)
|
||||||
|
|
||||||
|
set(CMAKE_CXX_STANDARD 20)
|
||||||
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
|
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||||
|
|
||||||
|
add_library(dts_core
|
||||||
|
src/domain/SpineVersion.cpp
|
||||||
|
src/atlas/AtlasParser.cpp
|
||||||
|
src/preview/RuntimePlugin.cpp
|
||||||
|
src/scan/SpineDataVersionDetector.cpp
|
||||||
|
src/editor/SpineEditorLocator.cpp
|
||||||
|
src/editor/SpineOutputParser.cpp
|
||||||
|
src/editor/SpineProbe.cpp
|
||||||
|
src/editor/SpineVersionAvailability.cpp
|
||||||
|
)
|
||||||
|
|
||||||
|
if(APPLE OR UNIX)
|
||||||
|
target_sources(dts_core PRIVATE src/platform/ProcessRunner_posix.cpp)
|
||||||
|
elseif(WIN32)
|
||||||
|
target_sources(dts_core PRIVATE src/platform/ProcessRunner_windows.cpp)
|
||||||
|
else()
|
||||||
|
message(FATAL_ERROR "Unsupported platform")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
target_include_directories(dts_core PUBLIC src runtime-api)
|
||||||
|
target_link_libraries(dts_core PRIVATE ${CMAKE_DL_LIBS})
|
||||||
|
|
||||||
|
if(MSVC)
|
||||||
|
target_compile_options(dts_core PRIVATE /W4 /permissive- /utf-8)
|
||||||
|
else()
|
||||||
|
target_compile_options(dts_core PRIVATE -Wall -Wextra -Wpedantic)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(DTS_BUILD_RUNTIME_PLUGINS)
|
||||||
|
include(cmake/SpineRuntime.cmake)
|
||||||
|
dts_add_runtime_adapter(38 "3.8" spine38 "spine-cpp/spine-cpp")
|
||||||
|
dts_add_runtime_adapter(40 "4.0" spine40 "spine-cpp/spine-cpp")
|
||||||
|
dts_add_runtime_adapter(41 "4.1" spine41 "spine-cpp/spine-cpp")
|
||||||
|
dts_add_runtime_adapter(42 "4.2" spine42 "spine-cpp/spine-cpp")
|
||||||
|
dts_add_runtime_adapter(43 "4.3" spine43 "spine-cpp")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
add_executable(datetospine-cli src/app/cli_main.cpp)
|
||||||
|
target_link_libraries(datetospine-cli PRIVATE dts_core)
|
||||||
|
|
||||||
|
if(DTS_BUILD_GUI)
|
||||||
|
find_package(Qt6 REQUIRED COMPONENTS Concurrent OpenGLWidgets Widgets)
|
||||||
|
add_executable(DateToSpine MACOSX_BUNDLE WIN32
|
||||||
|
src/app/gui_main.cpp
|
||||||
|
src/conversion/ConversionJob.cpp
|
||||||
|
src/conversion/ConversionJob.hpp
|
||||||
|
src/ui/MainWindow.cpp
|
||||||
|
src/ui/MainWindow.hpp
|
||||||
|
src/ui/SpinePreviewWidget.cpp
|
||||||
|
src/ui/SpinePreviewWidget.hpp
|
||||||
|
)
|
||||||
|
set_target_properties(DateToSpine PROPERTIES
|
||||||
|
AUTOMOC ON
|
||||||
|
MACOSX_BUNDLE_GUI_IDENTIFIER io.datetospine.desktop
|
||||||
|
MACOSX_BUNDLE_BUNDLE_NAME DateToSpine)
|
||||||
|
target_link_libraries(DateToSpine PRIVATE
|
||||||
|
dts_core
|
||||||
|
Qt6::Concurrent
|
||||||
|
Qt6::OpenGLWidgets
|
||||||
|
Qt6::Widgets
|
||||||
|
)
|
||||||
|
if(DTS_BUILD_RUNTIME_PLUGINS)
|
||||||
|
add_dependencies(DateToSpine
|
||||||
|
dts_runtime_38 dts_runtime_40 dts_runtime_41 dts_runtime_42 dts_runtime_43)
|
||||||
|
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"
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||||
|
"$<TARGET_FILE:dts_runtime_${runtime_line}>"
|
||||||
|
"$<TARGET_BUNDLE_DIR:DateToSpine>/Contents/PlugIns/runtimes/")
|
||||||
|
add_custom_command(TARGET DateToSpine POST_BUILD
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E make_directory
|
||||||
|
"$<TARGET_BUNDLE_DIR:DateToSpine>/Contents/PlugIns/runtimes"
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||||
|
"$<TARGET_FILE:dts_runtime_${runtime_line}>"
|
||||||
|
"$<TARGET_BUNDLE_DIR:DateToSpine>/Contents/PlugIns/runtimes/")
|
||||||
|
endforeach()
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(DTS_BUILD_TESTS)
|
||||||
|
enable_testing()
|
||||||
|
add_executable(dts_core_tests
|
||||||
|
tests/unit/CoreTests.cpp
|
||||||
|
)
|
||||||
|
target_link_libraries(dts_core_tests PRIVATE dts_core)
|
||||||
|
if(DTS_BUILD_RUNTIME_PLUGINS)
|
||||||
|
add_dependencies(dts_core_tests
|
||||||
|
dts_runtime_38 dts_runtime_40 dts_runtime_41 dts_runtime_42 dts_runtime_43)
|
||||||
|
target_compile_definitions(dts_core_tests PRIVATE
|
||||||
|
DTS_RUNTIME_38_PATH="$<TARGET_FILE:dts_runtime_38>"
|
||||||
|
DTS_RUNTIME_40_PATH="$<TARGET_FILE:dts_runtime_40>"
|
||||||
|
DTS_RUNTIME_41_PATH="$<TARGET_FILE:dts_runtime_41>"
|
||||||
|
DTS_RUNTIME_42_PATH="$<TARGET_FILE:dts_runtime_42>"
|
||||||
|
DTS_RUNTIME_43_PATH="$<TARGET_FILE:dts_runtime_43>"
|
||||||
|
DTS_TEST_38_JSON="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/3.8/examples/spineboy/export/spineboy-pro.json"
|
||||||
|
DTS_TEST_38_SKEL="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/3.8/examples/spineboy/export/spineboy-pro.skel"
|
||||||
|
DTS_TEST_38_ATLAS="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/3.8/examples/spineboy/export/spineboy.atlas"
|
||||||
|
DTS_TEST_40_JSON="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/4.0/examples/spineboy/export/spineboy-pro.json"
|
||||||
|
DTS_TEST_40_SKEL="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/4.0/examples/spineboy/export/spineboy-pro.skel"
|
||||||
|
DTS_TEST_40_ATLAS="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/4.0/examples/spineboy/export/spineboy.atlas"
|
||||||
|
DTS_TEST_41_JSON="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/4.1/examples/spineboy/export/spineboy-pro.json"
|
||||||
|
DTS_TEST_41_SKEL="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/4.1/examples/spineboy/export/spineboy-pro.skel"
|
||||||
|
DTS_TEST_41_ATLAS="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/4.1/examples/spineboy/export/spineboy.atlas"
|
||||||
|
DTS_TEST_42_JSON="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/4.2/examples/spineboy/export/spineboy-pro.json"
|
||||||
|
DTS_TEST_42_SKEL="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/4.2/examples/spineboy/export/spineboy-pro.skel"
|
||||||
|
DTS_TEST_42_ATLAS="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/4.2/examples/spineboy/export/spineboy.atlas"
|
||||||
|
DTS_TEST_SPINEBOY_JSON="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/4.3/examples/spineboy/export/spineboy-pro.json"
|
||||||
|
DTS_TEST_SPINEBOY_SKEL="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/4.3/examples/spineboy/export/spineboy-pro.skel"
|
||||||
|
DTS_TEST_SPINEBOY_ATLAS="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/4.3/examples/spineboy/export/spineboy.atlas")
|
||||||
|
endif()
|
||||||
|
add_test(NAME dts_core_tests COMMAND dts_core_tests)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
install(FILES third_party/spine-runtimes/4.3/spine-cpp/LICENSE
|
||||||
|
DESTINATION licenses
|
||||||
|
RENAME Spine-Runtimes-License.txt)
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
{
|
||||||
|
"version": 6,
|
||||||
|
"cmakeMinimumRequired": {
|
||||||
|
"major": 3,
|
||||||
|
"minor": 25,
|
||||||
|
"patch": 0
|
||||||
|
},
|
||||||
|
"configurePresets": [
|
||||||
|
{
|
||||||
|
"name": "macos-debug",
|
||||||
|
"displayName": "macOS Debug",
|
||||||
|
"generator": "Ninja",
|
||||||
|
"binaryDir": "${sourceDir}/build/macos-debug",
|
||||||
|
"cacheVariables": {
|
||||||
|
"CMAKE_BUILD_TYPE": "Debug",
|
||||||
|
"DTS_BUILD_TESTS": "ON"
|
||||||
|
},
|
||||||
|
"condition": {
|
||||||
|
"type": "equals",
|
||||||
|
"lhs": "${hostSystemName}",
|
||||||
|
"rhs": "Darwin"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "windows-msvc-debug",
|
||||||
|
"displayName": "Windows MSVC Debug",
|
||||||
|
"generator": "Visual Studio 17 2022",
|
||||||
|
"architecture": "x64",
|
||||||
|
"binaryDir": "${sourceDir}/build/windows-msvc-debug",
|
||||||
|
"cacheVariables": {
|
||||||
|
"DTS_BUILD_TESTS": "ON"
|
||||||
|
},
|
||||||
|
"condition": {
|
||||||
|
"type": "equals",
|
||||||
|
"lhs": "${hostSystemName}",
|
||||||
|
"rhs": "Windows"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"buildPresets": [
|
||||||
|
{
|
||||||
|
"name": "macos-debug",
|
||||||
|
"configurePreset": "macos-debug"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "windows-msvc-debug",
|
||||||
|
"configurePreset": "windows-msvc-debug",
|
||||||
|
"configuration": "Debug"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"testPresets": [
|
||||||
|
{
|
||||||
|
"name": "macos-debug",
|
||||||
|
"configurePreset": "macos-debug",
|
||||||
|
"output": { "outputOnFailure": true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "windows-msvc-debug",
|
||||||
|
"configurePreset": "windows-msvc-debug",
|
||||||
|
"configuration": "Debug",
|
||||||
|
"output": { "outputOnFailure": true }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
function(dts_add_spine_runtime target runtime_root)
|
||||||
|
if(NOT EXISTS "${runtime_root}/include/spine/spine.h")
|
||||||
|
message(FATAL_ERROR "Missing spine-cpp source at ${runtime_root}")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
file(GLOB_RECURSE runtime_sources CONFIGURE_DEPENDS
|
||||||
|
"${runtime_root}/src/*.cpp")
|
||||||
|
file(GLOB_RECURSE runtime_headers CONFIGURE_DEPENDS
|
||||||
|
"${runtime_root}/include/*.h")
|
||||||
|
|
||||||
|
add_library(${target} STATIC ${runtime_sources} ${runtime_headers})
|
||||||
|
target_include_directories(${target} PUBLIC "${runtime_root}/include")
|
||||||
|
set_target_properties(${target} PROPERTIES POSITION_INDEPENDENT_CODE ON)
|
||||||
|
|
||||||
|
if(MSVC)
|
||||||
|
target_compile_options(${target} PRIVATE /W3 /utf-8)
|
||||||
|
else()
|
||||||
|
target_compile_options(${target} PRIVATE -Wno-deprecated-declarations)
|
||||||
|
endif()
|
||||||
|
endfunction()
|
||||||
|
|
||||||
|
function(dts_add_runtime_adapter suffix version_line source_directory runtime_subdirectory)
|
||||||
|
set(runtime_root
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/${version_line}/${runtime_subdirectory}")
|
||||||
|
set(runtime_target "dts_spine_cpp_${suffix}")
|
||||||
|
set(adapter_target "dts_runtime_${suffix}")
|
||||||
|
dts_add_spine_runtime(${runtime_target} "${runtime_root}")
|
||||||
|
add_library(${adapter_target} SHARED
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/runtime-plugins/${source_directory}/RuntimeAdapter.cpp")
|
||||||
|
target_include_directories(${adapter_target} PRIVATE
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/runtime-api")
|
||||||
|
target_link_libraries(${adapter_target} PRIVATE ${runtime_target})
|
||||||
|
set_target_properties(${adapter_target} PROPERTIES
|
||||||
|
CXX_VISIBILITY_PRESET hidden
|
||||||
|
VISIBILITY_INLINES_HIDDEN YES
|
||||||
|
OUTPUT_NAME "dts-runtime-${version_line}")
|
||||||
|
endfunction()
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
# 产品需求
|
||||||
|
|
||||||
|
## 1. 产品目标
|
||||||
|
|
||||||
|
DateToSpine 帮助用户批量处理仅保留运行时导出文件、但缺失原始 `.spine` 工程的资源。软件需要在转换前验证资源并显示可交互动画预览,用户确认后才调用本地 Spine Editor 生成可编辑工程。
|
||||||
|
|
||||||
|
产品目标是“尽可能恢复可编辑工程”,不是承诺字节级还原原工程。运行时导出时已经丢失的数据无法凭空恢复。
|
||||||
|
|
||||||
|
## 2. 用户前提
|
||||||
|
|
||||||
|
- 用户对待处理资源拥有合法使用和转换权限。
|
||||||
|
- 用户电脑已安装并激活 Spine Editor。
|
||||||
|
- 需要生成 `.spine` 时,本地已有与资源主/次版本相匹配的 Editor 版本。
|
||||||
|
- 未安装 Editor 时,软件仍可扫描、校验、配对和预览,但不能生成 `.spine`。
|
||||||
|
|
||||||
|
## 3. 支持范围
|
||||||
|
|
||||||
|
### 3.1 骨骼数据
|
||||||
|
|
||||||
|
| 格式 | 支持状态 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `.json` | 正式支持 | 必须通过 Spine schema 识别,不能把普通 JSON 当作骨骼数据 |
|
||||||
|
| `.skel` | 正式支持 | 按导出版本选择匹配 Runtime 和 Editor |
|
||||||
|
| `.skel.bytes` | 候选兼容 | 通过内容识别,扩展名支持在技术原型后决定 |
|
||||||
|
|
||||||
|
### 3.2 Spine 版本
|
||||||
|
|
||||||
|
| 版本 | 支持级别 |
|
||||||
|
|---|---|
|
||||||
|
| 3.8.20+ | 正式支持 |
|
||||||
|
| 3.8.0—3.8.19 | 尽力识别,不作正式保证 |
|
||||||
|
| 4.0.x | 正式支持 |
|
||||||
|
| 4.1.x | 正式支持 |
|
||||||
|
| 4.2.x | 正式支持 |
|
||||||
|
| 4.3.x | 正式支持 |
|
||||||
|
| 3.7.x 及更早 | 不支持 |
|
||||||
|
|
||||||
|
### 3.3 Atlas 与纹理页
|
||||||
|
|
||||||
|
- `.atlas`、`.atlas.txt`
|
||||||
|
- 单页和多页 Atlas
|
||||||
|
- PNG
|
||||||
|
- JPG/JPEG
|
||||||
|
- WebP
|
||||||
|
- 同一 Atlas 中不同扩展名的多页纹理
|
||||||
|
- Straight Alpha 和 Premultiplied Alpha
|
||||||
|
|
||||||
|
其他图片格式通过可扩展解码接口保留未来支持空间,但不属于首版承诺。
|
||||||
|
|
||||||
|
## 4. 主用户流程
|
||||||
|
|
||||||
|
主界面只暴露四个动作:
|
||||||
|
|
||||||
|
1. 用户把完整导出资源或资源文件夹拖入窗口。
|
||||||
|
2. 软件自动配对并显示预览;有多套资源时用顶部下拉框切换。
|
||||||
|
3. 用户用底部下拉框切换动画。
|
||||||
|
4. 用户点击“一键转换为 .spine”并选择保存位置。
|
||||||
|
|
||||||
|
版本检测、Atlas 配对、图片处理、Editor 验证和结果校验均在后台自动完成。只有自动找不到 Spine Editor 时,才要求用户指定 Spine 应用或可执行文件路径。普通流程不提供 Editor 管理页或技术参数面板。
|
||||||
|
|
||||||
|
## 5. 功能需求
|
||||||
|
|
||||||
|
### 5.1 扫描与配对
|
||||||
|
|
||||||
|
- 支持文件、文件夹和批量拖放。
|
||||||
|
- 支持递归扫描,允许配置最大深度。
|
||||||
|
- 根据 Atlas 中声明的页面名查找纹理,不能只依赖同名前缀。
|
||||||
|
- 根据附件 path、文件名、目录距离和名称前缀对数据与 Atlas 评分。
|
||||||
|
- 自动结果必须显示置信度。
|
||||||
|
- 歧义配对必须由用户确认,不能静默选择。
|
||||||
|
- 单个失败组不能终止整个批次。
|
||||||
|
|
||||||
|
### 5.2 预览
|
||||||
|
|
||||||
|
- 默认循环播放,支持动画切换。
|
||||||
|
- 画布自动适应窗口。
|
||||||
|
- 支持 Mesh、权重、Clipping、双颜色 Tint 和四种混合模式。
|
||||||
|
- 显示缺失纹理、版本错误和 Runtime 加载错误。
|
||||||
|
|
||||||
|
### 5.3 Editor 管理
|
||||||
|
|
||||||
|
- 自动查找常见安装位置。
|
||||||
|
- 自动查找失败时才显示路径选择器,支持自定义应用、可执行文件或安装目录。
|
||||||
|
- 支持一个安装入口对应多个本地可用版本。
|
||||||
|
- 转换前执行实际版本探测。
|
||||||
|
- 不读取、存储或传输 Spine 激活码。
|
||||||
|
|
||||||
|
### 5.4 批量转换
|
||||||
|
|
||||||
|
- 默认串行调用 Spine Editor,扫描和静态校验可并行。
|
||||||
|
- 支持停止等待中的任务和取消当前子进程。
|
||||||
|
- 支持跳过、自动重命名和覆盖策略。
|
||||||
|
- 默认不覆盖输入文件。
|
||||||
|
- 正式结果验证通过前只写入任务临时目录。
|
||||||
|
- 每组资源产生可读日志和机器可读报告。
|
||||||
|
|
||||||
|
## 6. 恢复能力边界
|
||||||
|
|
||||||
|
通常可恢复:骨骼、Slots、Skins、Attachments、约束、动画、Deform、事件和绘制顺序。
|
||||||
|
|
||||||
|
如果源文件导出时未启用 Nonessential data,可能永久缺失:
|
||||||
|
|
||||||
|
- 骨骼颜色和图标
|
||||||
|
- Mesh 手工边
|
||||||
|
- 编辑器辅助信息
|
||||||
|
- 部分附件原始尺寸
|
||||||
|
- 原工程图片和音频目录设置
|
||||||
|
- 编辑器视图与工作区状态
|
||||||
|
|
||||||
|
报告必须区分:
|
||||||
|
|
||||||
|
- 完整恢复
|
||||||
|
- 主体恢复但缺少 Nonessential data
|
||||||
|
- 工程生成成功但图片关联需要注意
|
||||||
|
- 转换失败
|
||||||
|
|
||||||
|
## 7. 非功能需求
|
||||||
|
|
||||||
|
- macOS 和 Windows 功能一致。
|
||||||
|
- 运行时完全离线,不依赖 CDN 或在线服务。
|
||||||
|
- 支持中文、空格、Unicode 和较长路径。
|
||||||
|
- 所有外部进程使用参数数组启动,不能拼接 shell 命令。
|
||||||
|
- 崩溃或取消不能留下半成品覆盖正式结果。
|
||||||
|
- 日志不能包含用户授权信息或不必要的绝对路径。
|
||||||
|
- 普通操作错误必须以用户可理解的语言呈现。
|
||||||
|
|
||||||
|
## 8. 首版不包含
|
||||||
|
|
||||||
|
- Spine 3.7 及更早版本。
|
||||||
|
- 绕过 Spine Editor 授权或下载 Editor。
|
||||||
|
- 自行生成或逆向写入 `.spine` 格式。
|
||||||
|
- 动画编辑功能。
|
||||||
|
- 云端转换、资源上传或账号系统。
|
||||||
|
- 将官方示例资产打包给最终用户。
|
||||||
|
|
||||||
|
## 9. 产品验收标准
|
||||||
|
|
||||||
|
- 五个目标版本均可完成 JSON、SKEL 的预览和转换测试。
|
||||||
|
- 断网环境可完成已准备版本的完整流程。
|
||||||
|
- PNG、JPG/JPEG、WebP 页面可预览。
|
||||||
|
- 纹理只允许使用匹配版本 Spine Editor 的官方 Texture Unpacker 解包。
|
||||||
|
- 生成物能被匹配版本 Editor 重新读取。
|
||||||
|
- 骨骼、Skin、附件、动画和事件数量通过语义核对。
|
||||||
|
- 原始输入不被修改。
|
||||||
|
- 单任务失败不影响批次其他任务。
|
||||||
|
- 最终安装包不含官方测试资源和 Spine Editor。
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
# 系统架构
|
||||||
|
|
||||||
|
## 1. 技术基线
|
||||||
|
|
||||||
|
| 项目 | 选择 |
|
||||||
|
|---|---|
|
||||||
|
| 语言 | C++20(第三方 Runtime target 可使用其原生标准) |
|
||||||
|
| UI | Qt 6 Widgets |
|
||||||
|
| 预览画布 | `QOpenGLWidget` + 自研批次渲染器 |
|
||||||
|
| 构建 | CMake 3.25+、CMake Presets |
|
||||||
|
| Windows 编译器 | Visual Studio 2022 / MSVC v143 |
|
||||||
|
| macOS 编译器 | Apple Clang |
|
||||||
|
| 进程管理 | `QProcess` |
|
||||||
|
| 设置存储 | `QSettings` |
|
||||||
|
| JSON | Qt JSON |
|
||||||
|
| 图片加载 | Qt Image plugins,随程序离线部署 |
|
||||||
|
| 测试 | Qt Test 或 Catch2,原型结束时二选一 |
|
||||||
|
|
||||||
|
CMake 只用于开发构建,不是最终用户的运行依赖。Windows 可以使用 Visual Studio Generator 或 Ninja + MSVC。
|
||||||
|
|
||||||
|
## 2. 分层结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
UI
|
||||||
|
├─ 资源组表格
|
||||||
|
├─ 预览与播放控制
|
||||||
|
├─ Editor 管理
|
||||||
|
└─ 任务、日志与报告
|
||||||
|
↓
|
||||||
|
Application Services
|
||||||
|
├─ ScanService
|
||||||
|
├─ PreviewService
|
||||||
|
├─ EditorRegistry
|
||||||
|
├─ RecoveryCoordinator
|
||||||
|
└─ ReportService
|
||||||
|
↓
|
||||||
|
Domain
|
||||||
|
├─ AssetGroup
|
||||||
|
├─ SpineVersion
|
||||||
|
├─ EditorInstallation
|
||||||
|
├─ RecoveryJob
|
||||||
|
└─ RecoveryResult
|
||||||
|
↓
|
||||||
|
Infrastructure
|
||||||
|
├─ Filesystem / Process
|
||||||
|
├─ Runtime Plugin Host
|
||||||
|
├─ Spine CLI Adapter
|
||||||
|
├─ Atlas Unpackers
|
||||||
|
└─ Output Publisher
|
||||||
|
```
|
||||||
|
|
||||||
|
UI 只表达状态和用户意图,不能直接调用 Spine CLI 或解析 Atlas。
|
||||||
|
|
||||||
|
## 3. 建议目录
|
||||||
|
|
||||||
|
```text
|
||||||
|
DateToSpine/
|
||||||
|
CMakeLists.txt
|
||||||
|
CMakePresets.json
|
||||||
|
cmake/
|
||||||
|
docs/
|
||||||
|
licenses/
|
||||||
|
resources/
|
||||||
|
icons/
|
||||||
|
shaders/
|
||||||
|
translations/
|
||||||
|
src/
|
||||||
|
app/
|
||||||
|
domain/
|
||||||
|
scan/
|
||||||
|
atlas/
|
||||||
|
editor/
|
||||||
|
preview/
|
||||||
|
recovery/
|
||||||
|
report/
|
||||||
|
platform/
|
||||||
|
macos/
|
||||||
|
windows/
|
||||||
|
ui/
|
||||||
|
util/
|
||||||
|
runtime-api/
|
||||||
|
runtime-plugins/
|
||||||
|
spine38/
|
||||||
|
spine40/
|
||||||
|
spine41/
|
||||||
|
spine42/
|
||||||
|
spine43/
|
||||||
|
third_party/
|
||||||
|
spine-runtimes/
|
||||||
|
licenses/
|
||||||
|
tests/
|
||||||
|
unit/
|
||||||
|
integration/
|
||||||
|
compatibility/
|
||||||
|
manifests/
|
||||||
|
fixtures/
|
||||||
|
generated/
|
||||||
|
external/
|
||||||
|
packaging/
|
||||||
|
macos/
|
||||||
|
windows/
|
||||||
|
```
|
||||||
|
|
||||||
|
`tests/fixtures/external/` 用于本地准备的官方测试资源,不进入发行包。
|
||||||
|
|
||||||
|
## 4. 多版本 Runtime 隔离
|
||||||
|
|
||||||
|
五套 Runtime 不能直接链接进同一个主程序命名空间。每个版本编译成独立动态模块,并隐藏所有上游 C++ 符号,只导出稳定的 DateToSpine C ABI。
|
||||||
|
|
||||||
|
```text
|
||||||
|
dts-runtime-3_8
|
||||||
|
dts-runtime-4_0
|
||||||
|
dts-runtime-4_1
|
||||||
|
dts-runtime-4_2
|
||||||
|
dts-runtime-4_3
|
||||||
|
```
|
||||||
|
|
||||||
|
公共 ABI 只允许:
|
||||||
|
|
||||||
|
- 固定宽度整数
|
||||||
|
- POD 结构体
|
||||||
|
- UTF-8 字节串
|
||||||
|
- 不透明句柄
|
||||||
|
- 显式长度的数组
|
||||||
|
- 函数指针回调
|
||||||
|
|
||||||
|
禁止跨模块传递 Qt 对象、STL 容器、异常或 Spine Runtime 类。
|
||||||
|
|
||||||
|
每个适配模块承担:
|
||||||
|
|
||||||
|
- 对应版本 JSON/SKEL 读取
|
||||||
|
- Atlas 绑定
|
||||||
|
- 动画状态推进
|
||||||
|
- Skin/Animation 查询和切换
|
||||||
|
- 把绘制结果转换为统一顶点、索引和 draw command
|
||||||
|
- 把上游错误转换为统一错误码
|
||||||
|
|
||||||
|
## 5. 预览渲染边界
|
||||||
|
|
||||||
|
Runtime 插件负责骨骼计算和裁剪;主程序渲染器负责:
|
||||||
|
|
||||||
|
- 纹理创建和缓存
|
||||||
|
- 顶点/索引上传
|
||||||
|
- Shader
|
||||||
|
- Blend mode
|
||||||
|
- 相机、背景和调试覆盖层
|
||||||
|
- DPI 和窗口生命周期
|
||||||
|
|
||||||
|
Runtime 不直接创建 Qt 或 OpenGL 对象。这使 Runtime 适配可独立测试,也避免上下文所有权问题。
|
||||||
|
|
||||||
|
## 6. Spine CLI 边界
|
||||||
|
|
||||||
|
所有 Editor 调用必须经过 `ISpineCli`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
probe installation
|
||||||
|
probe version
|
||||||
|
import data
|
||||||
|
export normalized json
|
||||||
|
unpack atlas
|
||||||
|
inspect project
|
||||||
|
```
|
||||||
|
|
||||||
|
实现要求:
|
||||||
|
|
||||||
|
- 使用 `QProcess::setProgram` 和 `setArguments`。
|
||||||
|
- 不经过 shell。
|
||||||
|
- 每次调用有超时、取消令牌、工作目录和独立日志。
|
||||||
|
- 只终止本软件启动的子进程。
|
||||||
|
- 对 Windows 优先选择 `Spine.com`。
|
||||||
|
- 对 macOS 把 `.app` 规范化为内部可执行文件。
|
||||||
|
|
||||||
|
## 7. 任务并发
|
||||||
|
|
||||||
|
- 扫描、哈希、图片头校验可以在线程池执行。
|
||||||
|
- OpenGL 资源操作只在所属渲染线程执行。
|
||||||
|
- Spine Editor 调用默认全局串行。
|
||||||
|
- Spine 官方 Atlas 解包与工程导入全局串行,并受任务取消状态约束。
|
||||||
|
- 每个恢复任务有独立临时目录和取消状态。
|
||||||
|
|
||||||
|
## 8. 文件长度和复杂度约束
|
||||||
|
|
||||||
|
自研代码执行以下软上限,超过时必须在评审中说明或拆分:
|
||||||
|
|
||||||
|
| 文件类型 | 建议上限 |
|
||||||
|
|---|---:|
|
||||||
|
| 头文件 | 200 行 |
|
||||||
|
| 普通 `.cpp` | 350 行 |
|
||||||
|
| UI `.cpp` | 450 行 |
|
||||||
|
| 单元测试文件 | 500 行 |
|
||||||
|
|
||||||
|
同时执行:
|
||||||
|
|
||||||
|
- 一个类只有一个主要职责。
|
||||||
|
- `MainWindow` 不包含解析、转换和进程逻辑。
|
||||||
|
- 禁止无边界的 `Utils` 类。
|
||||||
|
- 平台代码不得散落到业务模块。
|
||||||
|
- 版本差异留在 Runtime adapter 和 version policy 中。
|
||||||
|
- 第三方上游源码保持原样,位于 `third_party/`,不纳入自研文件行数限制。
|
||||||
|
|
||||||
|
CI 将检查自研源文件行数、循环依赖和禁止目录引用。
|
||||||
|
|
||||||
|
## 9. 可测试性
|
||||||
|
|
||||||
|
核心依赖使用小接口注入:
|
||||||
|
|
||||||
|
```text
|
||||||
|
IFileSystem
|
||||||
|
IProcessRunner
|
||||||
|
ISpineCli
|
||||||
|
IRuntimePlugin
|
||||||
|
IAtlasUnpacker
|
||||||
|
IClock
|
||||||
|
IReportSink
|
||||||
|
```
|
||||||
|
|
||||||
|
单元测试不启动真实 Editor;集成和兼容测试才调用本地 Spine Editor。
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
# 版本识别与 Spine Editor 检测
|
||||||
|
|
||||||
|
## 1. 目标
|
||||||
|
|
||||||
|
在用户确认转换前,软件必须回答:
|
||||||
|
|
||||||
|
1. 资源由哪个 Spine 主/次版本导出?
|
||||||
|
2. 本机是否有可以处理该版本的 Spine Editor?
|
||||||
|
3. 配置路径是否指向可用的 Spine CLI?
|
||||||
|
4. 在断网状态下,该版本是否能实际启动和导入?
|
||||||
|
|
||||||
|
路径存在不等于 Editor 可用。当前实现先读取本机 Spine 更新缓存,只把已经存在的精确版本视为可转换;转换时再次执行同一门禁。
|
||||||
|
|
||||||
|
## 2. 资源版本识别
|
||||||
|
|
||||||
|
### 2.1 JSON
|
||||||
|
|
||||||
|
读取 `skeleton.spine` 字段,并同时验证最小 Spine schema:
|
||||||
|
|
||||||
|
- 根对象
|
||||||
|
- `bones` 数组
|
||||||
|
- 可选 `slots`、`skins`、`animations`
|
||||||
|
- 版本字段格式
|
||||||
|
|
||||||
|
只解析识别所需的小范围数据。无 Spine schema 的业务 JSON 必须忽略。
|
||||||
|
|
||||||
|
### 2.2 SKEL
|
||||||
|
|
||||||
|
识别顺序:
|
||||||
|
|
||||||
|
1. 对二进制头执行有界读取,尝试提取 hash 和版本字符串。
|
||||||
|
2. 用候选版本 Runtime 插件执行只读探测。
|
||||||
|
3. 必要时用已配置 Editor 的 info 命令验证。
|
||||||
|
4. 多个版本都失败则要求用户选择版本,并标记为未确认。
|
||||||
|
|
||||||
|
解析必须限制字符串长度、数组大小和文件大小,损坏文件不能导致无限分配。
|
||||||
|
|
||||||
|
## 3. Editor 数据模型
|
||||||
|
|
||||||
|
```text
|
||||||
|
EditorInstallation
|
||||||
|
id
|
||||||
|
displayName
|
||||||
|
platform
|
||||||
|
userSelectedPath
|
||||||
|
canonicalExecutable
|
||||||
|
discoverySource
|
||||||
|
lastVerifiedAt
|
||||||
|
baseVersion
|
||||||
|
signatureStatus
|
||||||
|
capabilities[]
|
||||||
|
|
||||||
|
EditorCapability
|
||||||
|
versionLine // 3.8, 4.0, 4.1, 4.2, 4.3
|
||||||
|
launchSelector // 可选 -u 参数或独立 executable
|
||||||
|
status
|
||||||
|
lastProbeResult
|
||||||
|
```
|
||||||
|
|
||||||
|
同一个安装入口可以声明多个本地版本能力;多个安装入口也可以映射到同一版本。用户可为每个版本选择首选项。
|
||||||
|
|
||||||
|
## 4. 自动发现
|
||||||
|
|
||||||
|
### 4.1 macOS
|
||||||
|
|
||||||
|
优先位置:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/Applications/Spine.app
|
||||||
|
~/Applications/Spine.app
|
||||||
|
```
|
||||||
|
|
||||||
|
补充来源:
|
||||||
|
|
||||||
|
- 系统应用索引
|
||||||
|
- 当前会话已知应用路径
|
||||||
|
- 历史用户配置
|
||||||
|
|
||||||
|
用户选择 `.app` 时规范化为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Spine.app/Contents/MacOS/Spine
|
||||||
|
```
|
||||||
|
|
||||||
|
不递归扫描整个磁盘。
|
||||||
|
|
||||||
|
### 4.2 Windows
|
||||||
|
|
||||||
|
发现来源:
|
||||||
|
|
||||||
|
- HKLM/HKCU 安装与卸载注册信息
|
||||||
|
- `Program Files`、`Program Files (x86)` 常见位置
|
||||||
|
- `PATH`
|
||||||
|
- 开始菜单快捷方式目标
|
||||||
|
- 历史用户配置
|
||||||
|
|
||||||
|
CLI 优先选择 `Spine.com`。如果用户选择 `Spine.exe`,查找同目录同名 `.com`;找不到时允许保存,但状态为“GUI 路径,CLI 待验证”。
|
||||||
|
|
||||||
|
## 5. 自定义路径
|
||||||
|
|
||||||
|
用户可选择:
|
||||||
|
|
||||||
|
- macOS `.app`
|
||||||
|
- 内部 Spine 可执行文件
|
||||||
|
- Windows `Spine.com` 或 `Spine.exe`
|
||||||
|
- 包含上述文件的目录
|
||||||
|
|
||||||
|
保存前执行路径规范化:
|
||||||
|
|
||||||
|
- 解析相对路径
|
||||||
|
- 清理多余分隔符
|
||||||
|
- 保留用户显示路径
|
||||||
|
- 保存 canonical path
|
||||||
|
- 记录文件标识和修改时间,用于发现安装被替换
|
||||||
|
|
||||||
|
路径移动或升级后,不自动删除旧配置,而是标记失效并允许重新定位。
|
||||||
|
|
||||||
|
## 6. 验证步骤
|
||||||
|
|
||||||
|
### 6.1 静态验证
|
||||||
|
|
||||||
|
- 文件存在且不是目录。
|
||||||
|
- 当前用户可执行。
|
||||||
|
- macOS bundle 结构或 Windows 同目录文件合理。
|
||||||
|
- 可选检查平台签名;旧版签名异常只警告,不直接否定合法安装。
|
||||||
|
|
||||||
|
### 6.2 基础进程验证
|
||||||
|
|
||||||
|
使用参数数组执行 `--version`,捕获:
|
||||||
|
|
||||||
|
- 退出码
|
||||||
|
- 标准输出和错误输出
|
||||||
|
- 启动耗时
|
||||||
|
- 报告版本
|
||||||
|
|
||||||
|
设置短超时,只终止该次探测进程。
|
||||||
|
|
||||||
|
### 6.3 版本能力验证
|
||||||
|
|
||||||
|
对用户配置的目标版本执行实际启动探测。若使用 `-u <version>` 选择器:
|
||||||
|
|
||||||
|
- 只允许已确认在本地缓存中存在的精确版本,例如资源线为 4.3 时选择本地最新的 `4.3.23`。
|
||||||
|
- 不使用 `--force`。
|
||||||
|
- 不使用 `latest`、`stable`、`beta` 或 `x.x.xx` 版本选择器。
|
||||||
|
- 不由 DateToSpine 发起下载。
|
||||||
|
- 缓存缺失时不启动 Spine Launcher,直接报告“本地未缓存目标版本”,从源头避免 Launcher 下载。
|
||||||
|
- 启动进程前再次检查精确缓存文件,避免检测后被移动导致意外联网。
|
||||||
|
|
||||||
|
当前 macOS 检查 `~/Library/Application Support/Spine/updates`,Windows 检查 `%APPDATA%/Spine/updates` 和 `%LOCALAPPDATA%/Spine/updates`。后续若 Spine 改变缓存结构,需要同步更新并重新做断网回归。
|
||||||
|
|
||||||
|
### 6.4 导入冒烟测试
|
||||||
|
|
||||||
|
在用户点击“深度验证”或首次转换前,可在临时目录执行小型导入:
|
||||||
|
|
||||||
|
- 输入与版本匹配的最小测试数据。
|
||||||
|
- 输出到临时目录。
|
||||||
|
- 验证退出码和 `.spine` 可再次读取。
|
||||||
|
- 完成立即清理临时结果。
|
||||||
|
|
||||||
|
发行版不能使用未获再分发确认的官方测试资产作为内置冒烟样本;需要自有最小样本或仅对用户当前资源执行验证。
|
||||||
|
|
||||||
|
## 7. 状态与用户提示
|
||||||
|
|
||||||
|
```text
|
||||||
|
Available
|
||||||
|
PathMissing
|
||||||
|
NotExecutable
|
||||||
|
NotSpine
|
||||||
|
CliCompanionMissing
|
||||||
|
VersionMismatch
|
||||||
|
VersionNotPrepared
|
||||||
|
NotActivated
|
||||||
|
ProbeTimedOut
|
||||||
|
PermissionDenied
|
||||||
|
LaunchFailed
|
||||||
|
Unknown
|
||||||
|
```
|
||||||
|
|
||||||
|
每个状态需要:
|
||||||
|
|
||||||
|
- 用户可读说明
|
||||||
|
- 技术详情
|
||||||
|
- 可执行的修复建议
|
||||||
|
- 是否允许预览
|
||||||
|
- 是否允许转换
|
||||||
|
|
||||||
|
资源全部载入后,主界面顶部直接显示当前资源的结果,例如“可转换 · Spine 4.3.23”或“缺少 Spine 4.1”。自动检测失败时,转换按钮才变为“指定 Spine 路径”。
|
||||||
|
|
||||||
|
## 8. 离线要求
|
||||||
|
|
||||||
|
DateToSpine 本身不联网。完整转换成立的前提是对应 Editor 版本已经在本机准备好并能在断网时运行。
|
||||||
|
|
||||||
|
离线验收必须在网络被禁用的独立测试环境进行,不能只通过代码审查推断。
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
# 恢复流水线
|
||||||
|
|
||||||
|
## 1. 状态机
|
||||||
|
|
||||||
|
```text
|
||||||
|
Discovered
|
||||||
|
→ Grouped
|
||||||
|
→ PreflightPassed
|
||||||
|
→ PreviewReady
|
||||||
|
→ UserConfirmed
|
||||||
|
→ EditorVerified
|
||||||
|
→ AtlasUnpacked
|
||||||
|
→ ProjectImported
|
||||||
|
→ ProjectVerified
|
||||||
|
→ Published
|
||||||
|
```
|
||||||
|
|
||||||
|
任一步骤失败进入 `Failed(stage, code, details)`。取消进入 `Cancelled`。失败与取消不能发布半成品。
|
||||||
|
|
||||||
|
## 2. 资源组
|
||||||
|
|
||||||
|
一个 `AssetGroup` 包含:
|
||||||
|
|
||||||
|
```text
|
||||||
|
id
|
||||||
|
skeletonDataPath
|
||||||
|
skeletonFormat
|
||||||
|
detectedVersion
|
||||||
|
atlasPath
|
||||||
|
atlasPages[]
|
||||||
|
candidateScore
|
||||||
|
scale
|
||||||
|
alphaMode
|
||||||
|
warnings[]
|
||||||
|
```
|
||||||
|
|
||||||
|
Atlas 页面名是纹理配对的权威来源。文件前缀只作为寻找数据与 Atlas 关系的辅助信号。
|
||||||
|
|
||||||
|
## 3. 预检
|
||||||
|
|
||||||
|
转换前检查:
|
||||||
|
|
||||||
|
- 数据格式和版本
|
||||||
|
- 版本是否在支持范围
|
||||||
|
- Atlas 基础语法
|
||||||
|
- 所有页面是否存在
|
||||||
|
- 图片解码和尺寸
|
||||||
|
- Atlas scale/PMA
|
||||||
|
- 输出命名冲突
|
||||||
|
- 目标 Editor 可用性
|
||||||
|
- 临时目录和输出目录可写性
|
||||||
|
- 磁盘空间粗略估计
|
||||||
|
|
||||||
|
预检不修改输入。
|
||||||
|
|
||||||
|
## 4. 用户确认边界
|
||||||
|
|
||||||
|
预览阶段不得生成正式 `.spine`。允许的临时操作仅包括:
|
||||||
|
|
||||||
|
- Runtime 加载
|
||||||
|
- 图片解码
|
||||||
|
- 静态校验
|
||||||
|
- Editor 只读版本探测
|
||||||
|
|
||||||
|
只有用户点击“开始转换”后,才能调用导入和解包命令。
|
||||||
|
|
||||||
|
## 5. 临时工作区
|
||||||
|
|
||||||
|
```text
|
||||||
|
job-<uuid>/
|
||||||
|
source-links/
|
||||||
|
normalized/
|
||||||
|
unpacked-images/
|
||||||
|
intermediate/
|
||||||
|
logs/
|
||||||
|
publish/
|
||||||
|
```
|
||||||
|
|
||||||
|
- 优先使用复制或只读访问,不能修改源文件。
|
||||||
|
- 是否允许安全硬链接作为优化,在跨平台验证后决定。
|
||||||
|
- 每个任务独立,名称使用内部 UUID,避免用户文件名注入路径。
|
||||||
|
|
||||||
|
## 6. Atlas 解包
|
||||||
|
|
||||||
|
1. 只调用匹配版本 Spine Editor 的官方 Texture Unpacker。
|
||||||
|
2. 校验页面、region 数量、输出尺寸和文件存在性。
|
||||||
|
3. 官方失败或验证失败时终止任务并显示原因,不使用其他解包实现。
|
||||||
|
4. 原始 `.atlas` 和打包纹理页只作为只读输入,不复制到最终结果。
|
||||||
|
5. 工程导入必须读取 Atlas 的 `scale` 并使用 `-s <数值>`,使骨骼与官方解包图片采用相同打包比例。
|
||||||
|
|
||||||
|
详细规则见 `06-图集解包设计.md`。
|
||||||
|
|
||||||
|
## 7. JSON 输入工程生成
|
||||||
|
|
||||||
|
1. 复制 JSON 到临时目录。
|
||||||
|
2. 保留所有未知字段。
|
||||||
|
3. 仅在必要时更新临时副本中的 `skeleton.images` 相对路径。
|
||||||
|
4. 使用匹配主/次版本的 Editor 导入。
|
||||||
|
5. 输出临时 `.spine`。
|
||||||
|
6. 使用 Editor info 或再次导出执行语义验证。
|
||||||
|
|
||||||
|
禁止修改源 JSON。
|
||||||
|
|
||||||
|
## 8. SKEL 输入工程生成
|
||||||
|
|
||||||
|
SKEL 的图片路径自动关联是技术原型重点。候选官方流程:
|
||||||
|
|
||||||
|
1. 匹配版本 Editor 把 SKEL 导入临时 `.spine`。
|
||||||
|
2. Editor 从临时工程导出规范化 JSON。
|
||||||
|
3. 在规范化 JSON 临时副本中设置 `skeleton.images`。
|
||||||
|
4. 匹配版本 Editor 再导入为最终候选 `.spine`。
|
||||||
|
5. 比较初次导入、规范化 JSON 和最终工程的语义计数。
|
||||||
|
|
||||||
|
此流程避免自行解析并序列化全部 SKEL,也避免修改未公开的 `.spine` 格式。
|
||||||
|
|
||||||
|
原型必须验证:
|
||||||
|
|
||||||
|
- 默认 JSON 导出是否保留全部运行时语义。
|
||||||
|
- 动画曲线、Deform、Linked Mesh、约束和事件是否无损。
|
||||||
|
- 图片相对路径是否被最终 `.spine` 保存。
|
||||||
|
- 3.8—4.3 是否行为一致。
|
||||||
|
|
||||||
|
若某版本失败,保留“工程 + images 目录 + 首次打开路径提示”作为降级方案,但正式自动化验收前必须由产品方确认是否接受。
|
||||||
|
|
||||||
|
## 9. Scale 与 Alpha
|
||||||
|
|
||||||
|
- Atlas 显式 `scale` 优先。
|
||||||
|
- 数据和 Atlas 都没有足够信息时,不猜测为确定值。
|
||||||
|
- 可提供 1、0.5、0.25 等候选并让用户预览比较。
|
||||||
|
- 用户可通过底部“去除预乘 Alpha”开关决定是否将解包图片转换为 Straight Alpha。
|
||||||
|
- 为保证各版本行为一致,官方解包使用临时 Atlas 副本并关闭其自动去预乘;开关开启后由本地像素处理统一完成转换。
|
||||||
|
- 临时 Atlas 不修改源文件,也不进入最终结果。
|
||||||
|
|
||||||
|
## 10. 结果验证
|
||||||
|
|
||||||
|
### 10.1 文件级
|
||||||
|
|
||||||
|
- `.spine` 存在且非空。
|
||||||
|
- 所有预期图片存在且可解码。
|
||||||
|
- 输出路径不逃逸目标目录。
|
||||||
|
- 临时文件未混入正式结果。
|
||||||
|
|
||||||
|
### 10.2 语义级
|
||||||
|
|
||||||
|
至少比较:
|
||||||
|
|
||||||
|
- 骨骼数和名称
|
||||||
|
- Slot 数和名称
|
||||||
|
- Skin 数和名称
|
||||||
|
- Attachment 类型与数量
|
||||||
|
- 约束类型与数量
|
||||||
|
- Animation 名称、数量和时长
|
||||||
|
- Event 名称和数量
|
||||||
|
- Deform timeline 存在性
|
||||||
|
|
||||||
|
Nonessential 缺失作为恢复等级,不作为运行时语义失败。
|
||||||
|
|
||||||
|
### 10.3 可读性
|
||||||
|
|
||||||
|
- 使用匹配 Editor 重新读取生成项目。
|
||||||
|
- 捕获所有 warning/error。
|
||||||
|
- 版本不匹配警告视为失败,除非用户启用实验模式。
|
||||||
|
|
||||||
|
## 11. 原子发布
|
||||||
|
|
||||||
|
验证通过后:
|
||||||
|
|
||||||
|
1. 在输出目录同一文件系统创建候选目录。
|
||||||
|
2. 应用重名策略。
|
||||||
|
3. 一次性重命名为正式结果。
|
||||||
|
4. 覆盖时先备份或移入可恢复位置。
|
||||||
|
5. 发布失败保留诊断,但不留下伪成功目录。
|
||||||
|
|
||||||
|
## 12. 输出结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
Output/
|
||||||
|
hero/
|
||||||
|
hero.spine
|
||||||
|
images/
|
||||||
|
body.png
|
||||||
|
face/
|
||||||
|
eye.png
|
||||||
|
```
|
||||||
|
|
||||||
|
当前简化产品不输出原始 Atlas、打包纹理页、日志或恢复报告。发生错误时直接弹窗给出失败阶段和 Spine 输出摘要;成功时弹窗显示完整输出目录。
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# 离线预览器
|
||||||
|
|
||||||
|
## 1. 目标
|
||||||
|
|
||||||
|
预览器在不调用 Spine Editor、不开启网络的情况下直接读取:
|
||||||
|
|
||||||
|
```text
|
||||||
|
.json/.skel + .atlas/.atlas.txt + texture pages
|
||||||
|
```
|
||||||
|
|
||||||
|
用户通过预览确认资源配对、版本、Scale、Alpha、动画和 Skin 后,才进入转换。
|
||||||
|
|
||||||
|
## 2. Runtime 版本矩阵
|
||||||
|
|
||||||
|
| 数据 | Runtime 插件 |
|
||||||
|
|---|---|
|
||||||
|
| 3.8.20+ | `dts-runtime-3_8` |
|
||||||
|
| 4.0.x | `dts-runtime-4_0` |
|
||||||
|
| 4.1.x | `dts-runtime-4_1` |
|
||||||
|
| 4.2.x | `dts-runtime-4_2` |
|
||||||
|
| 4.3.x | `dts-runtime-4_3` |
|
||||||
|
|
||||||
|
每个插件基于官方仓库对应分支的 `spine-cpp`。Runtime 与数据主/次版本必须一致。
|
||||||
|
|
||||||
|
## 3. 公共插件 ABI
|
||||||
|
|
||||||
|
公共 API 使用版本号和结构体大小实现向前兼容:
|
||||||
|
|
||||||
|
```text
|
||||||
|
dtsRuntimeGetApiVersion
|
||||||
|
dtsRuntimeCreate
|
||||||
|
dtsRuntimeDestroy
|
||||||
|
dtsRuntimeLoad
|
||||||
|
dtsRuntimeGetMetadata
|
||||||
|
dtsRuntimeGetAnimations
|
||||||
|
dtsRuntimeGetSkins
|
||||||
|
dtsRuntimeSetAnimation
|
||||||
|
dtsRuntimeSetSkin
|
||||||
|
dtsRuntimeSeek
|
||||||
|
dtsRuntimeUpdate
|
||||||
|
dtsRuntimeBuildFrame
|
||||||
|
dtsRuntimeGetLastError
|
||||||
|
```
|
||||||
|
|
||||||
|
设计规则:
|
||||||
|
|
||||||
|
- 调用方提供结构体 `size`。
|
||||||
|
- 插件返回的内存由插件释放。
|
||||||
|
- 错误不能以 C++ 异常越过 ABI。
|
||||||
|
- 所有字符串为 UTF-8,并带长度。
|
||||||
|
- 插件的所有上游符号默认隐藏。
|
||||||
|
- 主程序校验插件文件和 ABI 版本后才加载。
|
||||||
|
|
||||||
|
## 4. 统一绘制数据
|
||||||
|
|
||||||
|
插件输出与 Runtime 版本无关的 draw list:
|
||||||
|
|
||||||
|
```text
|
||||||
|
PreviewFrame
|
||||||
|
bounds
|
||||||
|
vertices[]
|
||||||
|
indices[]
|
||||||
|
commands[]
|
||||||
|
|
||||||
|
DrawCommand
|
||||||
|
textureHandle
|
||||||
|
firstIndex
|
||||||
|
indexCount
|
||||||
|
blendMode
|
||||||
|
premultipliedAlpha
|
||||||
|
```
|
||||||
|
|
||||||
|
顶点至少包含:
|
||||||
|
|
||||||
|
- position
|
||||||
|
- uv
|
||||||
|
- light color
|
||||||
|
- dark color
|
||||||
|
|
||||||
|
Clipping 优先在对应 Runtime/adapter 中完成,使主渲染器只处理最终三角形。
|
||||||
|
|
||||||
|
## 5. 纹理加载
|
||||||
|
|
||||||
|
主程序实现统一 Texture Provider:
|
||||||
|
|
||||||
|
- 根据 Atlas 页面路径读取本地文件。
|
||||||
|
- 拒绝 HTTP/HTTPS 和 data URL。
|
||||||
|
- 规范化相对路径并阻止目录逃逸。
|
||||||
|
- 支持 PNG、JPG/JPEG、WebP。
|
||||||
|
- 保留源 Alpha 模式元数据。
|
||||||
|
- 缓存按 canonical path、修改时间和文件大小失效。
|
||||||
|
|
||||||
|
图片解码插件必须随安装包部署,不能在运行时下载。
|
||||||
|
|
||||||
|
## 6. 渲染功能
|
||||||
|
|
||||||
|
首版必须支持:
|
||||||
|
|
||||||
|
- Region 和 Mesh
|
||||||
|
- Weighted Mesh、Linked Mesh
|
||||||
|
- Clipping
|
||||||
|
- Normal、Additive、Multiply、Screen
|
||||||
|
- 单颜色和 Two Color Tint
|
||||||
|
- PMA 和 Straight Alpha
|
||||||
|
- 多页 Atlas
|
||||||
|
- 高 DPI
|
||||||
|
- 骨骼、Mesh、边界、裁剪调试层
|
||||||
|
|
||||||
|
Shader 作为本地资源随程序部署,并在构建期或启动期从本地加载。
|
||||||
|
|
||||||
|
## 7. 播放状态
|
||||||
|
|
||||||
|
每个预览会话保存:
|
||||||
|
|
||||||
|
```text
|
||||||
|
selectedAnimation
|
||||||
|
selectedSkin
|
||||||
|
loop
|
||||||
|
playbackSpeed
|
||||||
|
time
|
||||||
|
paused
|
||||||
|
camera
|
||||||
|
background
|
||||||
|
debugFlags
|
||||||
|
```
|
||||||
|
|
||||||
|
切换资源组时保留各组会话,避免用户往返查看时丢失位置。
|
||||||
|
|
||||||
|
Seek 需要确定性:先恢复 setup pose,再从动画起点应用到目标时间,不能依赖累积浮点步进得到任意帧。
|
||||||
|
|
||||||
|
## 8. 错误隔离
|
||||||
|
|
||||||
|
首版采用动态插件;技术原型评估损坏 SKEL 是否可能使上游 Runtime 崩溃。如果无法通过输入边界检查和测试降低风险,发布版改为每个版本一个 helper process:
|
||||||
|
|
||||||
|
```text
|
||||||
|
UI ↔ local IPC ↔ preview-worker-4_2
|
||||||
|
```
|
||||||
|
|
||||||
|
是否进程隔离由原型的崩溃与模糊测试结果决定,不在未验证前锁死。
|
||||||
|
|
||||||
|
## 9. 性能目标
|
||||||
|
|
||||||
|
- 普通单骨骼资源首次预览在本地 SSD 上目标小于 1 秒。
|
||||||
|
- 预览默认 60 FPS;窗口不可见时暂停刷新。
|
||||||
|
- 大资源限制最大纹理尺寸、顶点数、附件数和单帧命令数。
|
||||||
|
- 纹理解码在线程池执行,OpenGL 创建在渲染线程执行。
|
||||||
|
- 切换动画不重复加载 Atlas 纹理。
|
||||||
|
|
||||||
|
## 10. 预览验收
|
||||||
|
|
||||||
|
- 五个版本官方示例的选定动画与官方参考视觉一致。
|
||||||
|
- 动画名、Skin 名和时长与 Runtime 数据一致。
|
||||||
|
- PMA/Straight Alpha 无明显黑边或白边。
|
||||||
|
- Mesh、Clipping、双颜色 Tint 和混合模式分别有测试样本。
|
||||||
|
- 缺失纹理不会导致崩溃,并能在画布上定位缺失项。
|
||||||
|
- 拔网后所有预览功能保持可用。
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Atlas 官方解包设计
|
||||||
|
|
||||||
|
## 1. 唯一实现
|
||||||
|
|
||||||
|
纹理恢复只调用用户本机、已授权且与数据版本匹配的 Spine Editor Texture Unpacker。项目不实现 Atlas 裁切、旋转、透明边恢复或图片重采样,也不存在自研兼容解包路径。
|
||||||
|
|
||||||
|
```text
|
||||||
|
本地匹配版本 Spine Texture Unpacker
|
||||||
|
↓
|
||||||
|
输出校验
|
||||||
|
↓
|
||||||
|
失败并向用户显示具体原因
|
||||||
|
```
|
||||||
|
|
||||||
|
官方解包失败、输出缺失或校验失败时,任务直接失败,不尝试以另一套图片算法生成看似成功的结果。
|
||||||
|
|
||||||
|
## 2. 官方调用
|
||||||
|
|
||||||
|
```text
|
||||||
|
Spine -u <本地精确缓存版本>
|
||||||
|
-i <Atlas 纹理页目录>
|
||||||
|
-o <临时 images 目录>
|
||||||
|
-c <Atlas 文件>
|
||||||
|
```
|
||||||
|
|
||||||
|
- 只允许精确的本地缓存版本,禁止 `latest`、通配版本和强制下载参数。
|
||||||
|
- 原始 Atlas 与纹理页保持只读。
|
||||||
|
- 输出先写入任务临时目录,验证成功后再发布。
|
||||||
|
- 记录退出码与进程输出;成功退出后仍检查是否实际生成图片。
|
||||||
|
- 最终结果不复制原始 `.atlas` 和打包纹理页。
|
||||||
|
|
||||||
|
## 3. PMA 处理
|
||||||
|
|
||||||
|
PMA 转换同样交给官方 Texture Unpacker:
|
||||||
|
|
||||||
|
- 开启“去除预乘 Alpha”时,原样传入源 Atlas,由 Spine 根据 `pma: true` 执行官方转换。
|
||||||
|
- 关闭时,仅建立临时 Atlas 元数据副本并把 `pma: true` 改为 `pma: false`,使官方解包器保留打包像素。
|
||||||
|
- 不再由 Qt 解码、逐像素处理或重新保存解包图片,避免 JPEG/WebP 二次有损编码以及错误的重复去预乘。
|
||||||
|
|
||||||
|
## 4. Atlas Scale 与工程导入
|
||||||
|
|
||||||
|
Texture Unpacker 输出的是 Atlas 实际打包尺寸。例如页面声明 `scale: 0.5` 时,解出的图片也是原图的一半像素尺寸,这不是压缩错误。
|
||||||
|
|
||||||
|
生成工程时必须把同一 Atlas 传给官方导入参数:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Spine -u <本地精确缓存版本>
|
||||||
|
-i <骨骼 JSON 或 SKEL>
|
||||||
|
-s <Atlas 的 scale 数值>
|
||||||
|
-o <目标 .spine>
|
||||||
|
--import
|
||||||
|
```
|
||||||
|
|
||||||
|
`-s <数值>` 让 Editor 按 Atlas 的打包比例缩放骨骼、附件和动画数据。使用数值是为了兼容 3.8—4.3;旧版 Editor 不接受 Atlas 路径作为 `-s` 的值。遗漏此参数会形成“附件位置沿用原始坐标、解包图片却是半尺寸”的工程,表现为局部图片缩小和脱节。
|
||||||
|
|
||||||
|
多页 Atlas 必须使用一致的 `scale`;未声明时按 `1.0`。若各页比例不同,任务会说明原因并停止,避免生成内部尺寸不一致的工程。
|
||||||
|
|
||||||
|
## 5. 验证与失败规则
|
||||||
|
|
||||||
|
每次解包后至少检查:
|
||||||
|
|
||||||
|
- 进程正常结束。
|
||||||
|
- `images/` 中存在可识别的图片输出。
|
||||||
|
- 最终 `.spine` 文件存在且非空。
|
||||||
|
- 3.8、4.0、4.1、4.2、4.3 均使用对应版本 Editor 做导入回归。
|
||||||
|
|
||||||
|
任何一步失败都弹窗显示所属阶段和 Spine 返回的原因,不切换到非官方实现。
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
# 测试策略
|
||||||
|
|
||||||
|
## 1. 测试目标
|
||||||
|
|
||||||
|
测试需要证明四件事:
|
||||||
|
|
||||||
|
1. 每个目标版本使用正确 Runtime 和 Editor。
|
||||||
|
2. 预览结果在语义和视觉上正确。
|
||||||
|
3. 生成 `.spine` 后运行时主体数据没有意外丢失。
|
||||||
|
4. 断网、损坏输入和批量失败情况下行为安全。
|
||||||
|
|
||||||
|
## 2. 测试层级
|
||||||
|
|
||||||
|
### 2.1 单元测试
|
||||||
|
|
||||||
|
不启动 Spine Editor:
|
||||||
|
|
||||||
|
- 版本字符串解析
|
||||||
|
- Spine JSON schema 识别
|
||||||
|
- SKEL 头部有界探测
|
||||||
|
- Atlas parser
|
||||||
|
- 自动配对评分
|
||||||
|
- 路径规范化和逃逸防护
|
||||||
|
- 输出重名策略
|
||||||
|
- 恢复状态机
|
||||||
|
- 报告序列化
|
||||||
|
- Editor 命令参数生成
|
||||||
|
|
||||||
|
### 2.2 组件测试
|
||||||
|
|
||||||
|
- 每个 Runtime 插件独立加载相应版本资源。
|
||||||
|
- Texture Provider 解码 PNG、JPG、WebP。
|
||||||
|
- 统一 draw list 校验。
|
||||||
|
- 官方解包结果的尺寸、Alpha 与 golden image 对比。
|
||||||
|
- Editor Locator 使用模拟注册信息和目录结构。
|
||||||
|
|
||||||
|
### 2.3 Editor 集成测试
|
||||||
|
|
||||||
|
需要本地已激活的 Spine Editor:
|
||||||
|
|
||||||
|
- `--version` 探测
|
||||||
|
- JSON 导入
|
||||||
|
- SKEL 导入
|
||||||
|
- Atlas 官方解包
|
||||||
|
- 临时工程导出规范化 JSON
|
||||||
|
- 最终 `.spine` 可重新读取
|
||||||
|
- 取消、超时和错误码映射
|
||||||
|
|
||||||
|
### 2.4 端到端测试
|
||||||
|
|
||||||
|
从目录扫描开始,经过预览、确认、解包、导入、验证和发布。检查输入目录完全未改变。
|
||||||
|
|
||||||
|
## 3. 版本矩阵
|
||||||
|
|
||||||
|
每个版本至少覆盖:
|
||||||
|
|
||||||
|
| 版本 | JSON | SKEL | 预览 | 官方解包 | 工程生成 | 语义回读 |
|
||||||
|
|---|---:|---:|---:|---:|---:|---:|
|
||||||
|
| 3.8.20+ | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 |
|
||||||
|
| 4.0.x | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 |
|
||||||
|
| 4.1.x | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 |
|
||||||
|
| 4.2.x | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 |
|
||||||
|
| 4.3.x | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 |
|
||||||
|
|
||||||
|
## 4. 功能样本矩阵
|
||||||
|
|
||||||
|
- Region
|
||||||
|
- Unweighted/Weighted/Linked Mesh
|
||||||
|
- IK、Transform、Path constraints
|
||||||
|
- Clipping
|
||||||
|
- Deform
|
||||||
|
- Draw order
|
||||||
|
- Events
|
||||||
|
- 多动画、多 Skin
|
||||||
|
- Two Color Tint
|
||||||
|
- Normal/Additive/Multiply/Screen
|
||||||
|
- 单页、多页 Atlas
|
||||||
|
- PMA、Straight Alpha
|
||||||
|
- PNG、JPG/JPEG、WebP
|
||||||
|
- Atlas scale
|
||||||
|
- Polygon packing
|
||||||
|
|
||||||
|
不要求一个样本覆盖所有功能,采用可追踪的样本能力清单。
|
||||||
|
|
||||||
|
## 5. 官方测试资源
|
||||||
|
|
||||||
|
开发阶段从官方 `EsotericSoftware/spine-runtimes` 仓库各版本分支取得对应样例:
|
||||||
|
|
||||||
|
```text
|
||||||
|
3.8
|
||||||
|
4.0
|
||||||
|
4.1
|
||||||
|
4.2
|
||||||
|
4.3
|
||||||
|
```
|
||||||
|
|
||||||
|
每份资源由 manifest 记录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
case id
|
||||||
|
source repository
|
||||||
|
branch
|
||||||
|
commit hash
|
||||||
|
relative paths
|
||||||
|
data version
|
||||||
|
features
|
||||||
|
expected animations/skins/counts
|
||||||
|
license reference
|
||||||
|
sha256
|
||||||
|
```
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- 不用 4.3 资源测试旧 Runtime。
|
||||||
|
- 测试基于固定 commit,不追随浮动分支。
|
||||||
|
- 官方资源只放在开发测试缓存或 CI 临时空间。
|
||||||
|
- `tests/fixtures/external/` 不进入发行打包输入。
|
||||||
|
- 发行产物检查中把官方资源文件名和哈希作为禁止项。
|
||||||
|
- 未经许可确认,不把官方资源提交到对外发布制品。
|
||||||
|
|
||||||
|
## 6. 自有与生成测试数据
|
||||||
|
|
||||||
|
除官方样例外,创建可自由分发的最小数据用于:
|
||||||
|
|
||||||
|
- 安装后冒烟测试
|
||||||
|
- 损坏输入测试
|
||||||
|
- Unicode 路径测试
|
||||||
|
- 极限尺寸测试
|
||||||
|
- 输出命名冲突测试
|
||||||
|
|
||||||
|
生成数据的来源、生成脚本和许可必须清晰。若生成 `.spine`/导出数据需要 Spine Editor,构建产物仍按许可审查。
|
||||||
|
|
||||||
|
## 7. 视觉回归
|
||||||
|
|
||||||
|
在固定 viewport、背景、时间点、Skin 和动画下截图:
|
||||||
|
|
||||||
|
- 与批准的 golden image 比较。
|
||||||
|
- 对 JPG/WebP 使用合理像素误差。
|
||||||
|
- 对 Alpha 边缘增加专门区域检测。
|
||||||
|
- 单独测试 PMA 黑边、Straight Alpha 白边问题。
|
||||||
|
- GPU 差异造成的小量误差采用阈值,不使用完全逐字节相等。
|
||||||
|
|
||||||
|
## 8. 语义回归
|
||||||
|
|
||||||
|
转换前后生成规范摘要并比较:
|
||||||
|
|
||||||
|
```text
|
||||||
|
bones
|
||||||
|
slots
|
||||||
|
skins
|
||||||
|
attachments by type
|
||||||
|
constraints by type
|
||||||
|
animations and duration
|
||||||
|
events
|
||||||
|
deform timelines
|
||||||
|
draw-order timelines
|
||||||
|
```
|
||||||
|
|
||||||
|
Nonessential 数据单独报告,不与运行时语义失败混合。
|
||||||
|
|
||||||
|
## 9. 异常与安全测试
|
||||||
|
|
||||||
|
- 截断 SKEL
|
||||||
|
- 超长字符串和伪造数组长度
|
||||||
|
- 深层/异常 JSON
|
||||||
|
- Atlas 路径逃逸
|
||||||
|
- 重复 region 和页面
|
||||||
|
- 缺失或损坏图片
|
||||||
|
- 巨大图片和解压炸弹式输入
|
||||||
|
- Editor 路径失效
|
||||||
|
- Editor 未激活
|
||||||
|
- 子进程卡住、崩溃、返回非零
|
||||||
|
- 用户在各阶段取消
|
||||||
|
- 输出目录无权限或空间不足
|
||||||
|
|
||||||
|
## 10. 离线测试
|
||||||
|
|
||||||
|
在 macOS 和 Windows 上分别执行:
|
||||||
|
|
||||||
|
1. 准备并验证对应 Editor 版本。
|
||||||
|
2. 禁用网络接口或在隔离环境阻断网络。
|
||||||
|
3. 启动 DateToSpine。
|
||||||
|
4. 完成扫描、预览、官方解包、生成和回读。
|
||||||
|
5. 记录任何网络连接尝试。
|
||||||
|
6. 确认不存在 CDN、在线字体、在线 Shader、更新或遥测依赖。
|
||||||
|
|
||||||
|
## 11. 发行物检查
|
||||||
|
|
||||||
|
对最终 `.app/.dmg/.exe/installer` 解包检查:
|
||||||
|
|
||||||
|
- 不含官方测试素材。
|
||||||
|
- 不含 Spine Editor。
|
||||||
|
- 包含 Runtime License 和 Third-Party Notices。
|
||||||
|
- 包含所需 Qt 图片插件,WebP 在断网环境可用。
|
||||||
|
- 不含开发日志、绝对源码路径、测试缓存或调试密钥。
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# 离线、打包与许可
|
||||||
|
|
||||||
|
## 1. 离线定义
|
||||||
|
|
||||||
|
DateToSpine 的“全功能离线”定义为:
|
||||||
|
|
||||||
|
- 应用本身不建立网络连接。
|
||||||
|
- 不使用 CDN、远程字体、远程 Shader、WebView 在线页面或在线 API。
|
||||||
|
- 不在运行时下载 Qt、Runtime、图片解码器或其他依赖。
|
||||||
|
- 不上传用户资源、路径、日志或统计数据。
|
||||||
|
- 不包含自动更新和联网遥测。
|
||||||
|
- 用户已经准备好的匹配版本 Spine Editor 可在断网环境中完成转换。
|
||||||
|
|
||||||
|
Spine Editor 是外部前置软件。DateToSpine 不控制 Editor 自身的授权策略,但必须在断网验收中验证既有激活和已缓存版本能够完成流程。
|
||||||
|
|
||||||
|
## 2. 构建期与运行期网络
|
||||||
|
|
||||||
|
- 开发机可以联网取得依赖和官方测试资源。
|
||||||
|
- 正式依赖必须锁定版本和校验值。
|
||||||
|
- 可复现构建应支持从内部或本地依赖缓存构建。
|
||||||
|
- 最终应用运行时不得使用网络。
|
||||||
|
- CI 下载的官方测试资源不进入打包阶段的输入集合。
|
||||||
|
|
||||||
|
## 3. macOS 打包
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- Apple Silicon 原生包。
|
||||||
|
- Intel 或 Universal 2 是否首发,由原型性能和构建环境决定。
|
||||||
|
- `.app` 和 `.dmg`。
|
||||||
|
- Qt framework、platform plugin、image plugin、Runtime 插件和本地资源完整部署。
|
||||||
|
- 正式发行执行代码签名和 notarization;这是发布流程,不是最终应用运行时联网依赖。
|
||||||
|
|
||||||
|
应用不得修改用户的 Spine.app 或其缓存。
|
||||||
|
|
||||||
|
## 4. Windows 打包
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- Windows 10/11 x64。
|
||||||
|
- MSVC 2022 v143。
|
||||||
|
- 使用 `/MD` 与 Qt 官方构建一致。
|
||||||
|
- 部署 MSVC Runtime、Qt platform plugin、imageformats 和 Runtime 插件。
|
||||||
|
- 安装包格式在原型后从 MSIX、WiX/MSI 或 Inno Setup 中确定。
|
||||||
|
- 正式发行建议代码签名。
|
||||||
|
|
||||||
|
最终用户不需要安装 CMake、Visual Studio 或 Qt SDK。
|
||||||
|
|
||||||
|
## 5. Runtime 许可
|
||||||
|
|
||||||
|
项目集成官方 Spine Runtimes 时必须遵守:
|
||||||
|
|
||||||
|
- [Spine Runtimes License Agreement](https://esotericsoftware.com/spine-runtimes-license)
|
||||||
|
- [Spine Editor License Agreement](https://esotericsoftware.com/spine-editor-license)
|
||||||
|
|
||||||
|
发行前需要法务或产品负责人确认最终分发模式。技术实现至少保证:
|
||||||
|
|
||||||
|
- `licenses/` 保存完整、未修改的 Runtime 许可文本。
|
||||||
|
- 安装包包含 Runtime 许可和版权声明。
|
||||||
|
- About/关于 页面提供第三方许可入口。
|
||||||
|
- 不隐藏用户需要合法 Spine 许可的前提。
|
||||||
|
- 不分发 Spine Editor、激活信息或 Editor 更新文件。
|
||||||
|
- Runtime 插件不作为通用 SDK 对外提供开发接口。
|
||||||
|
|
||||||
|
## 6. 官方测试资源
|
||||||
|
|
||||||
|
已确认规则:
|
||||||
|
|
||||||
|
- 官方 GitHub 仓库资源只用于开发、兼容性测试和 CI。
|
||||||
|
- 官方测试资源不进入最终安装包。
|
||||||
|
- 打包清单不包含 `tests/fixtures/external/`。
|
||||||
|
- 发行检查使用路径、文件名和 SHA-256 禁止列表防止误打包。
|
||||||
|
- 测试报告可记录资源 case id 和哈希,但不嵌入资源文件。
|
||||||
|
|
||||||
|
## 7. 第三方依赖清单
|
||||||
|
|
||||||
|
每个依赖记录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
name
|
||||||
|
version/commit
|
||||||
|
source URL
|
||||||
|
license
|
||||||
|
modified or unmodified
|
||||||
|
linked statically or dynamically
|
||||||
|
included platforms
|
||||||
|
notices path
|
||||||
|
```
|
||||||
|
|
||||||
|
至少包括:
|
||||||
|
|
||||||
|
- Qt 6
|
||||||
|
- Spine Runtimes 3.8/4.0/4.1/4.2/4.3
|
||||||
|
- 测试框架
|
||||||
|
- 可能引入的日志或打包依赖
|
||||||
|
|
||||||
|
新增依赖必须经过:离线可部署性、许可证、维护状态、体积和安全评审。
|
||||||
|
|
||||||
|
## 8. 网络禁止措施
|
||||||
|
|
||||||
|
- 不链接应用层 HTTP 客户端,除非未来需求重新评审。
|
||||||
|
- 资源加载器只接受本地文件协议。
|
||||||
|
- 代码扫描阻止 CDN URL 和动态依赖下载逻辑。
|
||||||
|
- 集成测试在网络阻断环境运行。
|
||||||
|
- 打包后执行连接监控测试。
|
||||||
|
|
||||||
|
文档中的官方链接仅供人阅读,不会被应用自动访问。
|
||||||
|
|
||||||
|
## 9. 发行目录白名单
|
||||||
|
|
||||||
|
打包采取白名单而不是“复制整个构建目录”:
|
||||||
|
|
||||||
|
```text
|
||||||
|
application executable
|
||||||
|
Qt runtime libraries/plugins
|
||||||
|
dts runtime plugins
|
||||||
|
icons/translations/shaders
|
||||||
|
licenses/notices
|
||||||
|
user documentation
|
||||||
|
```
|
||||||
|
|
||||||
|
明确排除:
|
||||||
|
|
||||||
|
```text
|
||||||
|
tests/
|
||||||
|
official sample assets
|
||||||
|
source repository metadata
|
||||||
|
developer caches
|
||||||
|
temporary recovery jobs
|
||||||
|
Spine Editor binaries
|
||||||
|
activation information
|
||||||
|
debug-only symbols(独立保存,不进入普通安装包)
|
||||||
|
```
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# 开发计划
|
||||||
|
|
||||||
|
## 1. 开发原则
|
||||||
|
|
||||||
|
- 先验证风险,再建设完整 UI。
|
||||||
|
- 每个阶段有可运行结果和退出标准。
|
||||||
|
- 未通过技术关卡时不扩大代码量掩盖问题。
|
||||||
|
- 文档、测试和代码同步更新。
|
||||||
|
- 当前文档批准后才进入功能开发。
|
||||||
|
|
||||||
|
## 2. 阶段 0:技术原型
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
|
||||||
|
验证决定产品能否成立的核心问题。
|
||||||
|
|
||||||
|
### 工作项
|
||||||
|
|
||||||
|
1. 在 macOS 上发现并验证已安装 Spine Editor。
|
||||||
|
2. 验证自定义 `.app`/可执行文件路径。
|
||||||
|
3. 调用 3.8、4.0、4.1、4.2、4.3 本地版本。
|
||||||
|
4. 为五个版本各构建最小 `spine-cpp` adapter。
|
||||||
|
5. 用 JSON、SKEL、Atlas 和纹理加载一帧预览数据。
|
||||||
|
6. 验证 QOpenGLWidget 绘制 Region、Mesh、Clipping 和混合模式。
|
||||||
|
7. 验证官方 Texture Unpacker。
|
||||||
|
8. 验证 SKEL → 临时 `.spine` → JSON → 设置 images → 最终 `.spine`。
|
||||||
|
9. 验证断网运行。
|
||||||
|
10. 在 Windows/MSVC 至少完成构建和 Editor CLI 冒烟测试。
|
||||||
|
|
||||||
|
### 技术关卡
|
||||||
|
|
||||||
|
- 五套 Runtime 可以隔离构建并通过统一 ABI 加载。
|
||||||
|
- 五个版本至少各有一个 JSON/SKEL 可预览。
|
||||||
|
- 本地 Editor 路径和版本能可靠识别。
|
||||||
|
- JSON/SKEL 可以生成可回读 `.spine`。
|
||||||
|
- 图片路径能自动关联,或明确证明需要采用哪种降级方案。
|
||||||
|
- 官方解包和 PMA 行为被记录。
|
||||||
|
- 断网时已准备版本可执行完整流程。
|
||||||
|
|
||||||
|
任何关卡失败都先更新架构决策,不进入大规模 UI 开发。
|
||||||
|
|
||||||
|
## 3. 阶段 1:工程骨架与领域模型
|
||||||
|
|
||||||
|
- CMake Presets 和平台 toolchain。
|
||||||
|
- 目录、target 和第三方依赖边界。
|
||||||
|
- `AssetGroup`、`SpineVersion`、`RecoveryJob`。
|
||||||
|
- 文件系统、进程和报告接口。
|
||||||
|
- 基础错误码和状态机。
|
||||||
|
- 单元测试框架和 CI 基线。
|
||||||
|
- 自研代码行数检查。
|
||||||
|
|
||||||
|
退出标准:macOS/Windows Debug 与 Release 构建通过,核心模型测试通过。
|
||||||
|
|
||||||
|
## 4. 阶段 2:扫描、配对与预检
|
||||||
|
|
||||||
|
- 递归扫描。
|
||||||
|
- JSON/SKEL 版本识别。
|
||||||
|
- Atlas parser。
|
||||||
|
- 页面解析和图片校验。
|
||||||
|
- 资源组配对评分。
|
||||||
|
- 手动修正模型。
|
||||||
|
- Unicode、长路径和安全边界。
|
||||||
|
|
||||||
|
退出标准:官方样例和异常样例能形成确定、可解释的资源组。
|
||||||
|
|
||||||
|
## 5. 阶段 3:离线预览器
|
||||||
|
|
||||||
|
- 五个 Runtime 插件完整实现。
|
||||||
|
- Texture Provider。
|
||||||
|
- OpenGL 批次渲染。
|
||||||
|
- 动画、Skin、Seek、速度控制。
|
||||||
|
- 调试显示。
|
||||||
|
- 视觉回归测试。
|
||||||
|
|
||||||
|
退出标准:五个版本功能矩阵通过,拔网预览正常。
|
||||||
|
|
||||||
|
## 6. 阶段 4:Editor 管理与转换
|
||||||
|
|
||||||
|
- macOS/Windows 自动发现。
|
||||||
|
- 自定义路径 UI。
|
||||||
|
- 版本能力映射。
|
||||||
|
- Spine CLI 封装。
|
||||||
|
- JSON/SKEL 导入。
|
||||||
|
- 图片路径规范化。
|
||||||
|
- 语义回读验证。
|
||||||
|
- 原子发布和恢复报告。
|
||||||
|
|
||||||
|
退出标准:五个版本端到端转换通过,输入未改变。
|
||||||
|
|
||||||
|
## 7. 阶段 5:Atlas 解包
|
||||||
|
|
||||||
|
- 官方解包器唯一流程。
|
||||||
|
- 官方结果验证。
|
||||||
|
- PMA、Scale、rotate、offset、多页和 polygon packing。
|
||||||
|
- PNG/JPG/WebP 测试。
|
||||||
|
|
||||||
|
退出标准:官方解包与失败原因可观察、可报告,且各版本具有视觉回归。
|
||||||
|
|
||||||
|
## 8. 阶段 6:批处理和用户体验
|
||||||
|
|
||||||
|
- 任务队列、取消和失败隔离。
|
||||||
|
- 批量重名策略。
|
||||||
|
- 进度、日志和错误建议。
|
||||||
|
- 设置迁移和会话恢复。
|
||||||
|
- 中英文界面。
|
||||||
|
- 无障碍和高 DPI 检查。
|
||||||
|
|
||||||
|
## 9. 阶段 7:发布准备
|
||||||
|
|
||||||
|
- macOS/Windows 安装包。
|
||||||
|
- Qt 和图片插件部署。
|
||||||
|
- Runtime 许可与 Third-Party Notices。
|
||||||
|
- 官方测试资源排除检查。
|
||||||
|
- 离线端到端测试。
|
||||||
|
- 签名、公证和干净机器安装测试。
|
||||||
|
- 性能、内存和损坏输入测试。
|
||||||
|
|
||||||
|
## 10. 建议评审点
|
||||||
|
|
||||||
|
每阶段完成后提交:
|
||||||
|
|
||||||
|
- 功能演示
|
||||||
|
- 自动测试结果
|
||||||
|
- 新增风险
|
||||||
|
- 文件长度报告
|
||||||
|
- 依赖与许可变更
|
||||||
|
- 下一阶段范围
|
||||||
|
|
||||||
|
## 11. 编码顺序建议
|
||||||
|
|
||||||
|
开始开发后的第一个提交不应是完整项目 UI,而应是阶段 0 的最小原型和实验记录。技术原型通过后,再固化公共接口和正式目录。
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# 风险与架构决策
|
||||||
|
|
||||||
|
## 1. 风险台账
|
||||||
|
|
||||||
|
| ID | 风险 | 影响 | 应对 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| R-01 | 不同 Spine 版本二进制格式不兼容 | 错误预览或导入失败 | 五套匹配 Runtime;Editor 主/次版本强校验 |
|
||||||
|
| R-02 | 3.8 早期格式变化 | 部分 3.8 文件无法读取 | 正式范围从 3.8.20 开始;旧文件尽力识别 |
|
||||||
|
| R-03 | SKEL 不含可直接修改的图片路径 | 生成工程显示 Missing | Editor 双重导入/导出原型;输出布局降级方案 |
|
||||||
|
| R-04 | 五套 Runtime 符号/API 冲突 | 无法链接或运行不稳定 | 独立动态模块、隐藏符号、稳定 C ABI |
|
||||||
|
| R-05 | 损坏输入使 Runtime 崩溃 | 主程序退出 | 有界预检、模糊测试;必要时改 helper process |
|
||||||
|
| R-06 | Spine Launcher 缺少目标缓存版本 | 离线转换失败 | 版本能力预检;不自动下载;明确修复提示 |
|
||||||
|
| R-07 | Editor 自身的联网或授权行为 | 离线验收失败 | 真正断网测试;明确前置条件;不修改或绕过 Editor |
|
||||||
|
| R-08 | PMA/WebP 边缘错误 | 预览或解包出现色边 | 格式矩阵与 Alpha 视觉回归;Alpha 处理交给官方解包器 |
|
||||||
|
| R-09 | Polygon-packed Mesh 难以还原原图 | 图片包含邻近像素或裁切错误 | 仅使用官方解包器;失败时报告限制并终止 |
|
||||||
|
| R-10 | 官方测试素材误入安装包 | 许可与体积风险 | 打包白名单、哈希禁止清单、发行物解包检查 |
|
||||||
|
| R-11 | Qt/Runtime 许可遗漏 | 无法合规发行 | 依赖清单、licenses、About 页面、发行关卡 |
|
||||||
|
| R-12 | 自定义路径和 Unicode 处理错误 | Editor 无法启动 | 参数数组、canonical path、双平台路径测试 |
|
||||||
|
| R-13 | 巨大资源耗尽内存 | 卡死或崩溃 | 输入限制、预算检查、按需解码、可取消任务 |
|
||||||
|
| R-14 | 跨补丁 Editor 行为变化 | 同一主/次版本结果不同 | 记录实际补丁;固定测试矩阵;报告转换环境 |
|
||||||
|
|
||||||
|
## 2. 已接受架构决策
|
||||||
|
|
||||||
|
### ADR-001:生成 `.spine` 使用官方 Editor
|
||||||
|
|
||||||
|
- 状态:接受
|
||||||
|
- 决策:调用本地已授权 Spine Editor,不自行写 `.spine`。
|
||||||
|
- 原因:`.spine` 是编辑工程格式;官方 CLI 已提供数据导入能力。
|
||||||
|
|
||||||
|
### ADR-002:版本范围为 3.8.20+ 至 4.3
|
||||||
|
|
||||||
|
- 状态:接受
|
||||||
|
- 决策:3.8.20+、4.0、4.1、4.2、4.3;不支持 3.8 以下。
|
||||||
|
- 原因:覆盖目标游戏资源,同时控制旧格式维护成本。
|
||||||
|
|
||||||
|
### ADR-003:预览进入首版
|
||||||
|
|
||||||
|
- 状态:接受
|
||||||
|
- 决策:用户确认转换前必须可离线预览。
|
||||||
|
- 原因:避免批量生成后才发现配对、Scale、Alpha 或版本错误。
|
||||||
|
|
||||||
|
### ADR-004:每个主/次版本独立 Runtime
|
||||||
|
|
||||||
|
- 状态:接受
|
||||||
|
- 决策:五个 Runtime 插件,通过统一 C ABI 接入。
|
||||||
|
- 原因:官方要求 Runtime 与数据主/次版本同步,API 也会变化。
|
||||||
|
|
||||||
|
### ADR-005:仅使用官方 Atlas 解包
|
||||||
|
|
||||||
|
- 状态:接受
|
||||||
|
- 决策:官方 Texture Unpacker 是唯一纹理解包实现;失败时任务失败。
|
||||||
|
- 原因:官方对自身 Atlas、PMA、旋转和多边形打包语义最权威,且软件以前置正版 Spine 为使用条件。
|
||||||
|
|
||||||
|
### ADR-006:运行期全离线
|
||||||
|
|
||||||
|
- 状态:接受
|
||||||
|
- 决策:无 CDN、下载、遥测、资源上传和在线 API。
|
||||||
|
- 原因:资源敏感性、稳定性和用户要求。
|
||||||
|
|
||||||
|
### ADR-007:官方测试资源不发行
|
||||||
|
|
||||||
|
- 状态:接受
|
||||||
|
- 决策:官方资源只用于开发/CI,最终安装包不包含。
|
||||||
|
- 原因:用户明确要求,并减少许可与安装体积风险。
|
||||||
|
|
||||||
|
### ADR-008:Qt 6 + CMake + MSVC/Apple Clang
|
||||||
|
|
||||||
|
- 状态:接受
|
||||||
|
- 决策:主程序 C++20,CMake 3.25+,Windows MSVC v143。
|
||||||
|
- 原因:跨平台 UI、进程管理、图片插件和成熟构建生态。
|
||||||
|
|
||||||
|
## 3. 原型后待决策事项
|
||||||
|
|
||||||
|
### ADR-P01:Runtime 插件还是 helper process
|
||||||
|
|
||||||
|
默认先验证动态插件。若损坏文件可导致不可接受的进程级崩溃,则采用 helper process 隔离。
|
||||||
|
|
||||||
|
### ADR-P02:macOS 架构
|
||||||
|
|
||||||
|
首发 Apple Silicon 或 Universal 2,根据目标用户和五套 Runtime 构建成本决定。
|
||||||
|
|
||||||
|
### ADR-P03:测试框架
|
||||||
|
|
||||||
|
Qt Test 与 Catch2 二选一,以 Runtime 插件和参数化兼容矩阵的便利性为主要标准。
|
||||||
|
|
||||||
|
### ADR-P04:SKEL 图片路径自动化方案
|
||||||
|
|
||||||
|
由 Editor 双重导入/导出原型结果决定正式流程及是否存在可接受降级。
|
||||||
|
|
||||||
|
### ADR-P05:Windows 安装器
|
||||||
|
|
||||||
|
在 MSIX、WiX/MSI、Inno Setup 中选择,需满足离线安装、签名和干净卸载。
|
||||||
|
|
||||||
|
## 4. 决策变更规则
|
||||||
|
|
||||||
|
变更已接受决策时必须记录:
|
||||||
|
|
||||||
|
- 变更原因
|
||||||
|
- 新证据或测试结果
|
||||||
|
- 对范围、兼容性和许可的影响
|
||||||
|
- 数据迁移或设置迁移方案
|
||||||
|
- 负责人和日期
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# 阶段 0 技术原型记录
|
||||||
|
|
||||||
|
## 记录日期
|
||||||
|
|
||||||
|
2026-09-07
|
||||||
|
|
||||||
|
## 当前结论
|
||||||
|
|
||||||
|
首批工程骨架已经建立并在 macOS Apple Silicon 上通过构建测试。此阶段证明多版本 Runtime 隔离、基础输入识别、本地 Editor 发现和跨平台进程抽象可以按既定架构继续推进;尚未证明完整动画预览或 `.spine` 恢复流程。
|
||||||
|
|
||||||
|
## 本机环境
|
||||||
|
|
||||||
|
- Apple Clang 17
|
||||||
|
- CMake 4.4.3(项目最低要求仍为 3.25)
|
||||||
|
- Ninja 1.13.2
|
||||||
|
- Qt Base 6.11.2
|
||||||
|
- Qt Image Formats 6.11.2
|
||||||
|
- Spine Launcher 4.3.06 Apple Silicon
|
||||||
|
- Launcher 当前观察到启动 Spine Editor 3.8.99 Professional
|
||||||
|
|
||||||
|
只安装 Qt Base 与 Qt Image Formats,没有安装 Qt WebEngine、Location、NetworkAuth 等与产品无关的 Qt 元组件。
|
||||||
|
|
||||||
|
## 固定 Runtime 提交
|
||||||
|
|
||||||
|
| Runtime | 官方分支 | 提交 |
|
||||||
|
|---|---|---|
|
||||||
|
| 3.8 | `3.8` | `8b4844bd4b193ba9e54487ed397a777993cbad56` |
|
||||||
|
| 4.0 | `4.0` | `425ce416bb218b28caeec47b317aa57cd7140375` |
|
||||||
|
| 4.1 | `4.1` | `77a5db0ec6d16331f5efbaa7662bba9355bd3424` |
|
||||||
|
| 4.2 | `4.2` | `e7dc1435fa4a0083ab431f1b28e083c14a1f5c68` |
|
||||||
|
| 4.3 | `4.3` | `4309c05c287d3f15da778e68f5d2a483fe10a6a3` |
|
||||||
|
|
||||||
|
当前只展开各分支的 `spine-cpp` 源码。4.3 官方 Spineboy 资源仅用于开发测试,不进入安装目标和发行包。
|
||||||
|
|
||||||
|
## 已实现
|
||||||
|
|
||||||
|
- CMake/C++20 工程和 macOS、Windows Preset。
|
||||||
|
- 标准 C++ 核心库,不依赖 Qt。
|
||||||
|
- Spine 版本值对象和正式支持范围判断。
|
||||||
|
- JSON 最小 Spine schema 与版本探测。
|
||||||
|
- SKEL 二进制头部 hash/version 有界读取。
|
||||||
|
- Atlas 多页、page、region、PMA、scale、rotate、bounds/legacy 字段基础解析。
|
||||||
|
- macOS 常见 Spine 路径发现和 `.app` 规范化。
|
||||||
|
- Windows 常见路径骨架和 `.exe` → `.com` 规范化。
|
||||||
|
- 不经过 shell 的 POSIX/Windows 子进程执行器。
|
||||||
|
- 超时后终止本软件启动的整个子进程树。
|
||||||
|
- Launcher 版本与实际 Editor 版本分离解析。
|
||||||
|
- 五个 Runtime 独立静态目标和动态 adapter。
|
||||||
|
- 稳定 C ABI 版本与 metadata 加载验证。
|
||||||
|
- 极简 Qt 桌面外壳:资源拖放/选择、资源切换、动画切换和一键转换。
|
||||||
|
- Editor 自动发现;仅在自动发现失败时请求自定义路径。
|
||||||
|
- 4.3 Runtime 已实际加载官方 JSON、SKEL 和 Atlas,并输出动画/Skin/骨骼元数据。
|
||||||
|
- 4.3 Runtime 已输出裁剪后的纹理三角形,Qt 画布可循环播放并切换真实动画。
|
||||||
|
- 4.3 beta 后缀版本和新版 SKEL 文件头识别。
|
||||||
|
- CLI:Editor 发现、路径探测、数据版本检查。
|
||||||
|
|
||||||
|
## 构建与测试结果
|
||||||
|
|
||||||
|
- `cmake --preset macos-debug`:通过。
|
||||||
|
- `cmake --build --preset macos-debug`:通过。
|
||||||
|
- 五个官方 Runtime:通过 Apple Clang 编译。
|
||||||
|
- 五个 Runtime adapter:通过动态加载和 ABI/版本检查。
|
||||||
|
- 核心单元测试:全部通过。
|
||||||
|
- 官方 Spineboy JSON/SKEL 真实加载测试:通过,识别到 11 个动画。
|
||||||
|
- Qt 应用 offscreen 启动冒烟测试:通过。
|
||||||
|
|
||||||
|
Spine 4.3 上游源码在 Apple Clang 下产生一条未覆盖枚举值的 warning。第三方源码没有被修改,该警告暂不影响构建,后续记录在上游警告基线中。
|
||||||
|
|
||||||
|
## 实际 Editor 探测发现
|
||||||
|
|
||||||
|
直接执行本机 `Spine --version` 时先报告 Launcher 4.3.06,随后启动 3.8.99 Professional。旧版 launcher 在当前系统产生字体兼容警告,并且探测可能长时间不退出。
|
||||||
|
|
||||||
|
因此代码已经采用:
|
||||||
|
|
||||||
|
- 分离解析 Launcher 和 Editor 版本。
|
||||||
|
- 探测超时。
|
||||||
|
- 进程树终止。
|
||||||
|
- “已观察到 Spine 但探测超时”独立状态。
|
||||||
|
|
||||||
|
仅看到 Launcher 版本不能代表目标 Editor 版本可用。
|
||||||
|
|
||||||
|
## 尚未完成的阶段 0 关卡
|
||||||
|
|
||||||
|
1. 对 3.8—4.2 执行真实资源预览。
|
||||||
|
2. 目标 Editor 版本选择与离线缓存能力验证。
|
||||||
|
3. 官方 Texture Unpacker 调用与结果验证。
|
||||||
|
4. JSON/SKEL 到 `.spine` 的端到端恢复。
|
||||||
|
5. SKEL 图片路径双重导入/导出方案验证。
|
||||||
|
6. Windows MSVC 构建与真实 Editor 测试。
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
先扩展 Runtime C ABI,以 4.3 adapter 实现真实资源加载、动画/Skin 元数据与 draw list;通过后把版本差异分别适配到 4.2、4.1、4.0 和 3.8。随后接 Qt OpenGL 预览画布,再进入 Editor 导入和官方解包流水线。
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# 极简界面阶段记录
|
||||||
|
|
||||||
|
## 交互结论
|
||||||
|
|
||||||
|
主界面已经收敛为“拖入、预览、切换动画、一键转换”。版本号、文件格式、校验状态和 Editor 管理等技术信息不再常驻显示。
|
||||||
|
|
||||||
|
## 当前可查看产出
|
||||||
|
|
||||||
|
- 空状态支持拖入 `.json`、`.skel`、`.atlas`、纹理或整个目录。
|
||||||
|
- 多套资源通过顶部单一选择框切换。
|
||||||
|
- 4.3 官方 Spineboy JSON/SKEL 可读取,动画选择框显示 11 个真实动画。
|
||||||
|
- 当前画布已使用官方 4.3 Runtime 绘制真实骨骼姿态,并默认循环播放 `idle`。
|
||||||
|
- 动画下拉框会实际切换 Runtime 动画,不再只是显示名称。
|
||||||
|
- “一键转换为 .spine”按钮已接入本地 Spine Editor 自动发现和异步导入入口。
|
||||||
|
- 自动发现 Editor 失败时才弹出路径选择,不再提供独立管理窗口。
|
||||||
|
- 软件没有网络请求。
|
||||||
|
|
||||||
|
界面截图:`阶段产出/极简界面-资源已载入.png`。
|
||||||
|
|
||||||
|
## 尚未宣称完成
|
||||||
|
|
||||||
|
- 当前真实动画渲染已覆盖 4.3,3.8—4.2 尚未接入统一绘制接口。
|
||||||
|
- 一键转换入口已形成,但官方解包、图片路径恢复和生成物回读验证尚未串成最终流水线。
|
||||||
|
- 3.8—4.2 Runtime 仍需补齐真实数据加载和绘制接口。
|
||||||
|
|
||||||
|
这些未完成项不会通过增加设置页面暴露给用户,仍保持后台自动化和极简主流程。
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# 4.3 动画预览阶段记录
|
||||||
|
|
||||||
|
## 阶段结果
|
||||||
|
|
||||||
|
4.3 官方 Runtime 已从“只读资源信息”升级为真实动画绘制。主界面现在会直接显示骨骼角色,而不是整张 Atlas 纹理。
|
||||||
|
|
||||||
|
## 已实现
|
||||||
|
|
||||||
|
- Runtime ABI 增加动画选择、逐帧更新和绘制命令接口。
|
||||||
|
- 4.3 adapter 创建 `Skeleton`、`AnimationState` 和 `SkeletonRenderer`。
|
||||||
|
- 输出世界坐标、UV、颜色、索引、纹理路径和混合模式。
|
||||||
|
- Runtime 内部完成 Mesh 和 Clipping 处理。
|
||||||
|
- Qt 独立预览画布按纹理三角形绘制并自动适应窗口。
|
||||||
|
- 支持 Normal、Additive、Multiply、Screen 四种混合模式映射。
|
||||||
|
- 默认循环播放动画列表第一项;列表为空时保持静态姿态,不启动动画播放。
|
||||||
|
- 用户切换下拉框时立即切换动画并循环播放。
|
||||||
|
- 预览画布尺寸独立于每帧骨骼边界;首次加载后锁定视口,动画过程不再忽大忽小。
|
||||||
|
- 默认以画布可容纳的最大比例显示,并保留少量安全边距。
|
||||||
|
- 滚动鼠标中键时,以鼠标所在位置为中心缩放画布。
|
||||||
|
- 单击鼠标中键时重置缩放和画布位置。
|
||||||
|
- 按住鼠标右键拖动时平移画布;右键短按时回到中心。
|
||||||
|
- 软件绘制路径消除了逐三角形抗锯齿造成的 Mesh 接缝。
|
||||||
|
|
||||||
|
## 测试证据
|
||||||
|
|
||||||
|
官方 Spineboy 4.3 JSON 测试已经验证:
|
||||||
|
|
||||||
|
- 动画可被选择。
|
||||||
|
- 每帧能产生绘制命令、顶点和三角形索引。
|
||||||
|
- 绘制命令引用的纹理文件存在。
|
||||||
|
- 时间推进后世界坐标确实发生变化。
|
||||||
|
- JSON 和 SKEL 仍能重复加载。
|
||||||
|
|
||||||
|
阶段截图:`阶段产出/骨骼动画预览-4.3.png`。
|
||||||
|
|
||||||
|
固定画布截图:`阶段产出/固定画布最大显示-4.3.png`。
|
||||||
|
|
||||||
|
## 后续范围
|
||||||
|
|
||||||
|
下一步复用相同 ABI 分别适配 4.2、4.1、4.0 和 3.8。各版本仍使用独立官方 Runtime,不把跨版本差异塞入界面代码。
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# 离线转换与多版本回归记录
|
||||||
|
|
||||||
|
## 1. 本阶段结果
|
||||||
|
|
||||||
|
- 资源载入完成后自动检测匹配版本线,并在顶部显示是否可转换。
|
||||||
|
- 4.3 资源在本机使用已缓存的 Spine `4.3.23`,不再把资源导出补丁号直接传给 Launcher。
|
||||||
|
- 只传精确缓存版本;禁止 `latest`、通配版本和 `--force`。
|
||||||
|
- 缓存缺失时不启动 Spine,转换任务内部也会二次拦截。
|
||||||
|
- 成功和失败都显示弹窗;失败弹窗包含具体阶段与进程输出摘要。
|
||||||
|
- 最终结果只包含 `.spine` 和 `images/`,不包含原始 Atlas 或打包纹理页。
|
||||||
|
- 底部增加“去除预乘 Alpha”开关,默认关闭。
|
||||||
|
|
||||||
|
界面产出:[离线转换与去预乘开关.png](阶段产出/离线转换与去预乘开关.png)
|
||||||
|
|
||||||
|
## 2. 输出结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
选择的输出位置/
|
||||||
|
spineboy-pro/
|
||||||
|
spineboy-pro.spine
|
||||||
|
images/
|
||||||
|
head.png
|
||||||
|
torso.png
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
同名目录存在时使用 `-2`、`-3` 后缀。所有内容先写入同一输出磁盘的临时目录,校验成功后再整体发布;失败不会留下伪成功目录。
|
||||||
|
|
||||||
|
## 3. Alpha 处理
|
||||||
|
|
||||||
|
官方 CLI 没有独立的去预乘开关,而是读取 Atlas 的 `pma` 值。为让 3.8—4.3 具有统一、可控的行为:
|
||||||
|
|
||||||
|
1. 原始 Atlas 与纹理页始终只读。
|
||||||
|
2. 开启开关时原样传入 Atlas,由官方 Texture Unpacker 根据 `pma` 完成转换。
|
||||||
|
3. 关闭开关时建立临时 Atlas 元数据副本,将 `pma:true` 改为 `pma:false`,再由官方解包器保留打包像素。
|
||||||
|
4. 不使用 Qt 对输出图片做逐像素变换或重新编码。
|
||||||
|
5. 临时 Atlas 随任务工作区清理,不进入结果目录。
|
||||||
|
|
||||||
|
## 4. 工程图片尺寸问题
|
||||||
|
|
||||||
|
已确认根因不是官方解包器压缩图片。测试 Atlas 声明 `scale: 0.5`,官方解包得到的 `head.png` 为 136×149、`front-shin.png` 为 41×92,符合实际打包尺寸。旧流程导入骨骼数据时没有传 Atlas Scale,导致附件坐标保持原始尺寸,而图片只有打包尺寸,于是局部图片缩小、脱节。
|
||||||
|
|
||||||
|
修复后的工程导入读取 Atlas 的 `scale`,再增加 `-s <数值>`,由匹配版本 Spine Editor 统一缩放骨骼、附件与动画数据。使用数值可以兼容不接受 Atlas 路径参数的 4.0 等旧版 Editor。
|
||||||
|
|
||||||
|
## 5. 多版本预览回归
|
||||||
|
|
||||||
|
每条版本线使用对应官方 `spine-cpp` 和该分支的 Spineboy 开发样本。样本仅用于开发测试,不进入安装包。
|
||||||
|
|
||||||
|
| 版本 | JSON | SKEL | 动画选择 | 绘制帧 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 3.8 | 通过 | 通过 | 通过 | 通过 |
|
||||||
|
| 4.0 | 通过 | 通过 | 通过 | 通过 |
|
||||||
|
| 4.1 | 通过 | 通过 | 通过 | 通过 |
|
||||||
|
| 4.2 | 通过 | 通过 | 通过 | 通过 |
|
||||||
|
| 4.3 | 通过 | 通过 | 通过 | 通过 |
|
||||||
|
|
||||||
|
3.8—4.1 共享一个旧版绘制适配层,4.2 和 4.3 使用各自 Runtime 自带的 `SkeletonRenderer`,避免将版本差异堆叠到主界面代码。
|
||||||
|
|
||||||
|
### 5.1 旧版 UV 方向修正
|
||||||
|
|
||||||
|
3.8—4.2 的 Runtime 输出沿用 Y 轴向上的世界坐标,而当前 Qt 画布以左上角为原点。适配层在输出预览顶点时对这些版本执行 `y = -y`;旧版 UV 已经与 Runtime 的三角形顶点顺序配套,不能再次翻转。4.3 已符合当前预览器方向,不执行该转换。
|
||||||
|
|
||||||
|
修复后截图:[旧版预览方向修复-4.2.png](阶段产出/旧版预览方向修复-4.2.png)
|
||||||
|
|
||||||
|
## 6. 本机实际转换回归
|
||||||
|
|
||||||
|
所有命令均选择已经存在的精确缓存版本:
|
||||||
|
|
||||||
|
| 资源线 | 本地 Editor | JSON 导入 | SKEL 导入 | 解包图片数 |
|
||||||
|
|---|---:|---:|---:|---:|
|
||||||
|
| 3.8 | 3.8.99 | 前一轮通过;本轮缓存不可用 | 前一轮通过;本轮缓存不可用 | 40(前一轮) |
|
||||||
|
| 4.0 | 4.0.64 | 55,295 字节 | 58,616 字节 | 40 |
|
||||||
|
| 4.1 | 4.1.24 | 55,483 字节 | 58,908 字节 | 40 |
|
||||||
|
| 4.2 | 4.2.43 | 50,811 字节 | 54,940 字节 | 40 |
|
||||||
|
| 4.3 | 4.3.23 | 49,603 字节 | 49,905 字节 | 40 |
|
||||||
|
|
||||||
|
4.2 A/B 回导验证:未传比例时 `hip.y=247.27`、前胫骨长度为 `128.77`;传 `-s 0.5` 后分别为 `123.64` 和 `64.39`,Mesh 顶点也同步减半,而图片维持官方解包尺寸。这说明工程数据与图片比例已恢复一致。
|
||||||
|
|
||||||
|
本轮 3.8 缓存文件在回归前已不可用,ARM Launcher 无法加载旧 x86 版本,因此遵守离线约束,没有尝试联网补齐。3.8 官方样本不声明 Atlas `scale`,修复路径会使用 `1.0`,与原导入行为一致;待本机重新具备可运行的 3.8 Editor 后仍需补跑本轮实机导入。
|
||||||
|
|
||||||
|
自动测试与完整构建通过。官方测试资源仍位于开发期 `third_party` 目录,安装规则只复制 Runtime 许可文件。
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# DateToSpine 开发文档
|
||||||
|
|
||||||
|
## 文档状态
|
||||||
|
|
||||||
|
- 状态:技术原型开发中,极简主界面已形成
|
||||||
|
- 文档基线:2026-09-07
|
||||||
|
- 当前目录包含设计文档、C++/Qt 工程、Runtime 适配器和自动测试
|
||||||
|
|
||||||
|
## 产品概述
|
||||||
|
|
||||||
|
DateToSpine 是一个面向 macOS 和 Windows 的离线桌面工具,用于扫描、预览并批量恢复 Spine 游戏运行时资源:
|
||||||
|
|
||||||
|
```text
|
||||||
|
.skel/.json + .atlas/.atlas.txt + texture pages
|
||||||
|
↓
|
||||||
|
可编辑的 .spine 工程
|
||||||
|
```
|
||||||
|
|
||||||
|
软件内置匹配版本的官方 `spine-cpp` Runtime 以实现转换前预览。生成 `.spine` 时,用户电脑必须已经安装、激活并准备好与资源版本匹配的 Spine Editor。
|
||||||
|
|
||||||
|
## 已确认的产品决策
|
||||||
|
|
||||||
|
1. 正式支持 Spine `3.8.20+`、`4.0.x`、`4.1.x`、`4.2.x`、`4.3.x`。
|
||||||
|
2. 不支持 Spine 3.8 以下版本。
|
||||||
|
3. 转换前必须支持离线预览,用户确认后才生成 `.spine`。
|
||||||
|
4. 五个 Spine 主/次版本使用各自对应的官方 Runtime。
|
||||||
|
5. 生成 `.spine` 必须调用本地已授权的 Spine Editor,不自行编写或逆向 `.spine` 文件。
|
||||||
|
6. Atlas 纹理只使用 Spine 官方 Texture Unpacker 解包,不提供自研兜底。
|
||||||
|
7. 软件运行期不使用 CDN、在线 API、在线依赖下载、遥测或资源上传。
|
||||||
|
8. 官方测试资源仅用于开发和 CI 测试,不进入最终安装包。
|
||||||
|
9. 最终安装包只携带 Runtime 许可、第三方许可和必要声明,不携带 Spine Editor 或官方示例资源。
|
||||||
|
10. 自研源文件按职责拆分,禁止出现数千行的业务代码文件。
|
||||||
|
11. 主界面只保留拖入资源、预览/动画切换和一键转换;仅在 Editor 自动识别失败时请求路径。
|
||||||
|
|
||||||
|
## 文档索引
|
||||||
|
|
||||||
|
- [01-产品需求.md](01-产品需求.md):产品范围、用户流程和验收标准
|
||||||
|
- [02-系统架构.md](02-系统架构.md):技术架构、模块边界和代码约束
|
||||||
|
- [03-版本识别与编辑器检测.md](03-版本识别与编辑器检测.md):版本识别、本地 Editor 检测和自定义路径
|
||||||
|
- [04-恢复流水线.md](04-恢复流水线.md):恢复流水线、输出规则和错误处理
|
||||||
|
- [05-离线预览器.md](05-离线预览器.md):多版本 Runtime 与离线预览器
|
||||||
|
- [06-图集解包设计.md](06-图集解包设计.md):官方唯一解包、PMA 与 Atlas Scale 设计
|
||||||
|
- [07-测试策略.md](07-测试策略.md):测试矩阵、官方测试资源管理和验收
|
||||||
|
- [08-离线打包与许可.md](08-离线打包与许可.md):离线、发行与许可要求
|
||||||
|
- [09-开发计划.md](09-开发计划.md):阶段计划、技术关卡与完成定义
|
||||||
|
- [10-风险与架构决策.md](10-风险与架构决策.md):风险台账与架构决策记录
|
||||||
|
- [11-阶段0技术原型记录.md](11-阶段0技术原型记录.md):首批代码、构建证据和后续关卡
|
||||||
|
- [12-极简界面阶段记录.md](12-极简界面阶段记录.md):精简后的界面范围、当前产出与未完成项
|
||||||
|
- [13-四点三动画预览记录.md](13-四点三动画预览记录.md):4.3 真实骨骼绘制、动画切换和测试证据
|
||||||
|
- [14-离线转换与多版本回归记录.md](14-离线转换与多版本回归记录.md):本地版本门禁、完整输出、PMA 开关和 3.8—4.3 回归证据
|
||||||
|
|
||||||
|
## 官方参考资料
|
||||||
|
|
||||||
|
- [Spine Command Line Interface](https://en.esotericsoftware.com/spine-command-line-interface)
|
||||||
|
- [Importing skeleton data](https://esotericsoftware.com/blog/Importing-skeleton-data)
|
||||||
|
- [Spine versioning](https://esotericsoftware.com/spine-versioning)
|
||||||
|
- [spine-cpp Runtime Documentation](https://esotericsoftware.com/spine-cpp)
|
||||||
|
- [Spine JSON format](https://esotericsoftware.com/spine-json-format)
|
||||||
|
- [Spine binary format](https://esotericsoftware.com/spine-binary-format)
|
||||||
|
- [Spine Runtimes repository](https://github.com/EsotericSoftware/spine-runtimes)
|
||||||
|
- [Spine Runtimes License](https://esotericsoftware.com/spine-runtimes-license)
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 387 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 169 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 952 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 124 KiB |
@@ -0,0 +1,90 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#if defined(_WIN32)
|
||||||
|
#define DTS_RUNTIME_EXPORT __declspec(dllexport)
|
||||||
|
#else
|
||||||
|
#define DTS_RUNTIME_EXPORT __attribute__((visibility("default")))
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
enum { DTS_RUNTIME_API_VERSION = 3 };
|
||||||
|
|
||||||
|
enum DtsRuntimeCapability {
|
||||||
|
DTS_RUNTIME_CAP_SKELETON_METADATA = 1u << 0,
|
||||||
|
DTS_RUNTIME_CAP_DRAW_COMMANDS = 1u << 1
|
||||||
|
};
|
||||||
|
|
||||||
|
typedef struct DtsRuntimeInfo {
|
||||||
|
uint32_t structSize;
|
||||||
|
uint32_t apiVersion;
|
||||||
|
const char* spineVersionLine;
|
||||||
|
const char* adapterBuild;
|
||||||
|
uint32_t capabilities;
|
||||||
|
} DtsRuntimeInfo;
|
||||||
|
|
||||||
|
typedef void* DtsRuntimeHandle;
|
||||||
|
|
||||||
|
typedef struct DtsLoadRequest {
|
||||||
|
uint32_t structSize;
|
||||||
|
const char* skeletonPathUtf8;
|
||||||
|
const char* atlasPathUtf8;
|
||||||
|
float scale;
|
||||||
|
} DtsLoadRequest;
|
||||||
|
|
||||||
|
typedef struct DtsSkeletonInfo {
|
||||||
|
uint32_t structSize;
|
||||||
|
uint32_t boneCount;
|
||||||
|
uint32_t slotCount;
|
||||||
|
uint32_t skinCount;
|
||||||
|
uint32_t animationCount;
|
||||||
|
uint32_t eventCount;
|
||||||
|
uint32_t constraintCount;
|
||||||
|
} DtsSkeletonInfo;
|
||||||
|
|
||||||
|
typedef struct DtsRenderVertex {
|
||||||
|
float x;
|
||||||
|
float y;
|
||||||
|
float u;
|
||||||
|
float v;
|
||||||
|
uint32_t color;
|
||||||
|
} DtsRenderVertex;
|
||||||
|
|
||||||
|
typedef struct DtsDrawCommand {
|
||||||
|
uint32_t structSize;
|
||||||
|
const DtsRenderVertex* vertices;
|
||||||
|
uint32_t vertexCount;
|
||||||
|
const uint16_t* indices;
|
||||||
|
uint32_t indexCount;
|
||||||
|
const char* texturePathUtf8;
|
||||||
|
uint32_t blendMode;
|
||||||
|
} DtsDrawCommand;
|
||||||
|
|
||||||
|
DTS_RUNTIME_EXPORT uint32_t dtsRuntimeGetApiVersion(void);
|
||||||
|
DTS_RUNTIME_EXPORT int32_t dtsRuntimeGetInfo(DtsRuntimeInfo* info);
|
||||||
|
DTS_RUNTIME_EXPORT DtsRuntimeHandle dtsRuntimeCreate(void);
|
||||||
|
DTS_RUNTIME_EXPORT void dtsRuntimeDestroy(DtsRuntimeHandle handle);
|
||||||
|
DTS_RUNTIME_EXPORT int32_t dtsRuntimeLoad(DtsRuntimeHandle handle, const DtsLoadRequest* request);
|
||||||
|
DTS_RUNTIME_EXPORT int32_t dtsRuntimeGetSkeletonInfo(
|
||||||
|
DtsRuntimeHandle handle, DtsSkeletonInfo* info);
|
||||||
|
DTS_RUNTIME_EXPORT const char* dtsRuntimeGetAnimationName(
|
||||||
|
DtsRuntimeHandle handle, uint32_t index);
|
||||||
|
DTS_RUNTIME_EXPORT float dtsRuntimeGetAnimationDuration(
|
||||||
|
DtsRuntimeHandle handle, uint32_t index);
|
||||||
|
DTS_RUNTIME_EXPORT const char* dtsRuntimeGetSkinName(
|
||||||
|
DtsRuntimeHandle handle, uint32_t index);
|
||||||
|
DTS_RUNTIME_EXPORT const char* dtsRuntimeGetLastError(DtsRuntimeHandle handle);
|
||||||
|
DTS_RUNTIME_EXPORT int32_t dtsRuntimeSetAnimation(
|
||||||
|
DtsRuntimeHandle handle, const char* animationNameUtf8, int32_t loop);
|
||||||
|
DTS_RUNTIME_EXPORT int32_t dtsRuntimeUpdate(DtsRuntimeHandle handle, float deltaSeconds);
|
||||||
|
DTS_RUNTIME_EXPORT uint32_t dtsRuntimeGetDrawCommandCount(DtsRuntimeHandle handle);
|
||||||
|
DTS_RUNTIME_EXPORT int32_t dtsRuntimeGetDrawCommand(
|
||||||
|
DtsRuntimeHandle handle, uint32_t index, DtsDrawCommand* command);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
#include "RuntimeApi.h"
|
||||||
|
|
||||||
|
#include <spine/Animation.h>
|
||||||
|
#include <spine/AnimationState.h>
|
||||||
|
#include <spine/AnimationStateData.h>
|
||||||
|
#include <spine/Atlas.h>
|
||||||
|
#include <spine/ClippingAttachment.h>
|
||||||
|
#include <spine/Extension.h>
|
||||||
|
#include <spine/MeshAttachment.h>
|
||||||
|
#include <spine/RegionAttachment.h>
|
||||||
|
#include <spine/Skeleton.h>
|
||||||
|
#include <spine/SkeletonBinary.h>
|
||||||
|
#include <spine/SkeletonClipping.h>
|
||||||
|
#include <spine/SkeletonData.h>
|
||||||
|
#include <spine/SkeletonJson.h>
|
||||||
|
#include <spine/Skin.h>
|
||||||
|
#include <spine/Slot.h>
|
||||||
|
#include <spine/SlotData.h>
|
||||||
|
#include <spine/TextureLoader.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cctype>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#ifndef DTS_SPINE_LINE
|
||||||
|
#error DTS_SPINE_LINE must be defined before including LegacyRuntimeAdapter.inl
|
||||||
|
#endif
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
struct TextureRecord { std::string path; };
|
||||||
|
class MetadataTextureLoader final : public spine::TextureLoader {
|
||||||
|
public:
|
||||||
|
void load(spine::AtlasPage& page, const spine::String& path) override {
|
||||||
|
#if defined(DTS_SPINE_41)
|
||||||
|
page.texture = new TextureRecord{path.buffer()};
|
||||||
|
#else
|
||||||
|
page.setRendererObject(new TextureRecord{path.buffer()});
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
void unload(void* texture) override { delete static_cast<TextureRecord*>(texture); }
|
||||||
|
};
|
||||||
|
struct RenderBatch {
|
||||||
|
std::vector<DtsRenderVertex> vertices;
|
||||||
|
std::vector<std::uint16_t> indices;
|
||||||
|
std::string texturePath;
|
||||||
|
std::uint32_t blendMode = 0;
|
||||||
|
};
|
||||||
|
struct RuntimeContext {
|
||||||
|
MetadataTextureLoader textureLoader;
|
||||||
|
std::unique_ptr<spine::Atlas> atlas;
|
||||||
|
std::unique_ptr<spine::SkeletonData> skeletonData;
|
||||||
|
std::unique_ptr<spine::Skeleton> skeleton;
|
||||||
|
std::unique_ptr<spine::AnimationStateData> animationStateData;
|
||||||
|
std::unique_ptr<spine::AnimationState> animationState;
|
||||||
|
spine::SkeletonClipping clipper;
|
||||||
|
std::vector<std::string> animationNames;
|
||||||
|
std::vector<float> animationDurations;
|
||||||
|
std::vector<std::string> skinNames;
|
||||||
|
std::vector<RenderBatch> renderBatches;
|
||||||
|
std::string error;
|
||||||
|
};
|
||||||
|
RuntimeContext* context(DtsRuntimeHandle handle) { return static_cast<RuntimeContext*>(handle); }
|
||||||
|
std::string lowercaseExtension(const std::string& path) {
|
||||||
|
const auto dot = path.find_last_of('.');
|
||||||
|
auto extension = dot == std::string::npos ? std::string{} : path.substr(dot);
|
||||||
|
std::transform(extension.begin(), extension.end(), extension.begin(),
|
||||||
|
[](unsigned char value) { return static_cast<char>(std::tolower(value)); });
|
||||||
|
return extension;
|
||||||
|
}
|
||||||
|
void collectMetadata(RuntimeContext& value) {
|
||||||
|
value.animationNames.clear();
|
||||||
|
value.animationDurations.clear();
|
||||||
|
auto& animations = value.skeletonData->getAnimations();
|
||||||
|
for (std::size_t index = 0; index < animations.size(); ++index) {
|
||||||
|
value.animationNames.emplace_back(animations[index]->getName().buffer());
|
||||||
|
value.animationDurations.push_back(animations[index]->getDuration());
|
||||||
|
}
|
||||||
|
value.skinNames.clear();
|
||||||
|
auto& skins = value.skeletonData->getSkins();
|
||||||
|
for (std::size_t index = 0; index < skins.size(); ++index)
|
||||||
|
value.skinNames.emplace_back(skins[index]->getName().buffer());
|
||||||
|
}
|
||||||
|
spine::AtlasRegion* regionFor(spine::RegionAttachment& attachment) {
|
||||||
|
#if defined(DTS_SPINE_41)
|
||||||
|
return static_cast<spine::AtlasRegion*>(attachment.getRegion());
|
||||||
|
#else
|
||||||
|
return static_cast<spine::AtlasRegion*>(attachment.getRendererObject());
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
spine::AtlasRegion* regionFor(spine::MeshAttachment& attachment) {
|
||||||
|
#if defined(DTS_SPINE_41)
|
||||||
|
return static_cast<spine::AtlasRegion*>(attachment.getRegion());
|
||||||
|
#else
|
||||||
|
return static_cast<spine::AtlasRegion*>(attachment.getRendererObject());
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
std::uint32_t packedColor(spine::Skeleton& skeleton, spine::Slot& slot,
|
||||||
|
spine::Color& attachment) {
|
||||||
|
const auto component = [](float value) {
|
||||||
|
return static_cast<std::uint32_t>(std::clamp(value, 0.0F, 1.0F) * 255.0F);
|
||||||
|
};
|
||||||
|
const auto& skeletonColor = skeleton.getColor();
|
||||||
|
const auto& slotColor = slot.getColor();
|
||||||
|
const auto red = component(skeletonColor.r * slotColor.r * attachment.r);
|
||||||
|
const auto green = component(skeletonColor.g * slotColor.g * attachment.g);
|
||||||
|
const auto blue = component(skeletonColor.b * slotColor.b * attachment.b);
|
||||||
|
const auto alpha = component(skeletonColor.a * slotColor.a * attachment.a);
|
||||||
|
return (alpha << 24) | (red << 16) | (green << 8) | blue;
|
||||||
|
}
|
||||||
|
void appendBatch(RuntimeContext& value, spine::Slot& slot, spine::Color& color,
|
||||||
|
spine::AtlasRegion* region, spine::Vector<float>& positions,
|
||||||
|
spine::Vector<float>& uvs, spine::Vector<unsigned short>& indices) {
|
||||||
|
if (!region || !region->page) return;
|
||||||
|
#if defined(DTS_SPINE_41)
|
||||||
|
auto* texture = static_cast<TextureRecord*>(region->page->texture);
|
||||||
|
#else
|
||||||
|
auto* texture = static_cast<TextureRecord*>(region->page->getRendererObject());
|
||||||
|
#endif
|
||||||
|
if (!texture) return;
|
||||||
|
auto* sourcePositions = &positions;
|
||||||
|
auto* sourceUvs = &uvs;
|
||||||
|
auto* sourceIndices = &indices;
|
||||||
|
if (value.clipper.isClipping()) {
|
||||||
|
value.clipper.clipTriangles(positions, indices, uvs, 2);
|
||||||
|
sourcePositions = &value.clipper.getClippedVertices();
|
||||||
|
sourceUvs = &value.clipper.getClippedUVs();
|
||||||
|
sourceIndices = &value.clipper.getClippedTriangles();
|
||||||
|
}
|
||||||
|
RenderBatch batch;
|
||||||
|
const auto vertexCount = sourcePositions->size() / 2;
|
||||||
|
batch.vertices.reserve(vertexCount);
|
||||||
|
const auto packed = packedColor(*value.skeleton, slot, color);
|
||||||
|
for (std::size_t index = 0; index < vertexCount; ++index)
|
||||||
|
batch.vertices.push_back({(*sourcePositions)[index * 2], -(*sourcePositions)[index * 2 + 1],
|
||||||
|
(*sourceUvs)[index * 2], (*sourceUvs)[index * 2 + 1], packed});
|
||||||
|
batch.indices.assign(sourceIndices->buffer(), sourceIndices->buffer() + sourceIndices->size());
|
||||||
|
batch.texturePath = texture->path;
|
||||||
|
batch.blendMode = static_cast<std::uint32_t>(slot.getData().getBlendMode());
|
||||||
|
value.renderBatches.push_back(std::move(batch));
|
||||||
|
}
|
||||||
|
void collectDrawCommands(RuntimeContext& value) {
|
||||||
|
value.renderBatches.clear();
|
||||||
|
spine::Vector<float> positions;
|
||||||
|
spine::Vector<unsigned short> quadIndices;
|
||||||
|
quadIndices.add(0); quadIndices.add(1); quadIndices.add(2);
|
||||||
|
quadIndices.add(2); quadIndices.add(3); quadIndices.add(0);
|
||||||
|
auto& drawOrder = value.skeleton->getDrawOrder();
|
||||||
|
for (std::size_t index = 0; index < drawOrder.size(); ++index) {
|
||||||
|
auto& slot = *drawOrder[index];
|
||||||
|
auto* attachment = slot.getAttachment();
|
||||||
|
if (!attachment) {
|
||||||
|
value.clipper.clipEnd(slot);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (attachment->getRTTI().isExactly(spine::RegionAttachment::rtti)) {
|
||||||
|
auto& region = *static_cast<spine::RegionAttachment*>(attachment);
|
||||||
|
positions.setSize(8, 0);
|
||||||
|
#if defined(DTS_SPINE_41)
|
||||||
|
region.computeWorldVertices(slot, positions, 0, 2);
|
||||||
|
#else
|
||||||
|
region.computeWorldVertices(slot.getBone(), positions, 0, 2);
|
||||||
|
#endif
|
||||||
|
appendBatch(value, slot, region.getColor(), regionFor(region),
|
||||||
|
positions, region.getUVs(), quadIndices);
|
||||||
|
} else if (attachment->getRTTI().isExactly(spine::MeshAttachment::rtti)) {
|
||||||
|
auto& mesh = *static_cast<spine::MeshAttachment*>(attachment);
|
||||||
|
positions.setSize(mesh.getWorldVerticesLength(), 0);
|
||||||
|
mesh.computeWorldVertices(slot, 0, mesh.getWorldVerticesLength(),
|
||||||
|
positions.buffer(), 0, 2);
|
||||||
|
appendBatch(value, slot, mesh.getColor(), regionFor(mesh),
|
||||||
|
positions, mesh.getUVs(), mesh.getTriangles());
|
||||||
|
} else if (attachment->getRTTI().isExactly(spine::ClippingAttachment::rtti)) {
|
||||||
|
value.clipper.clipStart(slot, static_cast<spine::ClippingAttachment*>(attachment));
|
||||||
|
}
|
||||||
|
value.clipper.clipEnd(slot);
|
||||||
|
}
|
||||||
|
value.clipper.clipEnd();
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
namespace spine {
|
||||||
|
SpineExtension* getDefaultExtension() { return new DefaultSpineExtension(); }
|
||||||
|
} // namespace spine
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
uint32_t dtsRuntimeGetApiVersion() { return DTS_RUNTIME_API_VERSION; }
|
||||||
|
int32_t dtsRuntimeGetInfo(DtsRuntimeInfo* info) {
|
||||||
|
if (!info || info->structSize < sizeof(DtsRuntimeInfo)) return -1;
|
||||||
|
info->apiVersion = DTS_RUNTIME_API_VERSION;
|
||||||
|
info->spineVersionLine = DTS_SPINE_LINE;
|
||||||
|
info->adapterBuild = "preview-1";
|
||||||
|
info->capabilities = DTS_RUNTIME_CAP_SKELETON_METADATA | DTS_RUNTIME_CAP_DRAW_COMMANDS;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
DtsRuntimeHandle dtsRuntimeCreate() { return new RuntimeContext; }
|
||||||
|
void dtsRuntimeDestroy(DtsRuntimeHandle handle) { delete context(handle); }
|
||||||
|
int32_t dtsRuntimeLoad(DtsRuntimeHandle handle, const DtsLoadRequest* request) {
|
||||||
|
auto* value = context(handle);
|
||||||
|
if (!value || !request || request->structSize < sizeof(DtsLoadRequest)
|
||||||
|
|| !request->skeletonPathUtf8 || !request->atlasPathUtf8) return -1;
|
||||||
|
value->error.clear();
|
||||||
|
value->renderBatches.clear();
|
||||||
|
value->animationState.reset();
|
||||||
|
value->animationStateData.reset();
|
||||||
|
value->skeleton.reset();
|
||||||
|
value->skeletonData.reset();
|
||||||
|
value->atlas.reset();
|
||||||
|
value->atlas = std::make_unique<spine::Atlas>(request->atlasPathUtf8, &value->textureLoader, true);
|
||||||
|
const float scale = request->scale > 0 ? request->scale : 1.0F;
|
||||||
|
if (lowercaseExtension(request->skeletonPathUtf8) == ".json") {
|
||||||
|
spine::SkeletonJson loader(value->atlas.get());
|
||||||
|
loader.setScale(scale);
|
||||||
|
value->skeletonData.reset(loader.readSkeletonDataFile(request->skeletonPathUtf8));
|
||||||
|
if (!value->skeletonData) value->error = loader.getError().buffer();
|
||||||
|
} else {
|
||||||
|
spine::SkeletonBinary loader(value->atlas.get());
|
||||||
|
loader.setScale(scale);
|
||||||
|
value->skeletonData.reset(loader.readSkeletonDataFile(request->skeletonPathUtf8));
|
||||||
|
if (!value->skeletonData) value->error = loader.getError().buffer();
|
||||||
|
}
|
||||||
|
if (!value->skeletonData) {
|
||||||
|
if (value->error.empty()) value->error = "Spine Runtime could not load skeleton data.";
|
||||||
|
return -2;
|
||||||
|
}
|
||||||
|
collectMetadata(*value);
|
||||||
|
value->skeleton = std::make_unique<spine::Skeleton>(value->skeletonData.get());
|
||||||
|
value->animationStateData = std::make_unique<spine::AnimationStateData>(value->skeletonData.get());
|
||||||
|
value->animationState = std::make_unique<spine::AnimationState>(value->animationStateData.get());
|
||||||
|
if (!value->animationNames.empty())
|
||||||
|
value->animationState->setAnimation(0, value->animationNames.front().c_str(), true);
|
||||||
|
value->animationState->apply(*value->skeleton);
|
||||||
|
value->skeleton->updateWorldTransform();
|
||||||
|
collectDrawCommands(*value);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
int32_t dtsRuntimeGetSkeletonInfo(DtsRuntimeHandle handle, DtsSkeletonInfo* info) {
|
||||||
|
auto* value = context(handle);
|
||||||
|
if (!value || !value->skeletonData || !info || info->structSize < sizeof(DtsSkeletonInfo)) return -1;
|
||||||
|
auto* data = value->skeletonData.get();
|
||||||
|
info->boneCount = static_cast<uint32_t>(data->getBones().size());
|
||||||
|
info->slotCount = static_cast<uint32_t>(data->getSlots().size());
|
||||||
|
info->skinCount = static_cast<uint32_t>(value->skinNames.size());
|
||||||
|
info->animationCount = static_cast<uint32_t>(value->animationNames.size());
|
||||||
|
info->eventCount = static_cast<uint32_t>(data->getEvents().size());
|
||||||
|
info->constraintCount = static_cast<uint32_t>(data->getIkConstraints().size()
|
||||||
|
+ data->getTransformConstraints().size() + data->getPathConstraints().size());
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const char* dtsRuntimeGetAnimationName(DtsRuntimeHandle handle, uint32_t index) {
|
||||||
|
auto* value = context(handle);
|
||||||
|
return value && index < value->animationNames.size() ? value->animationNames[index].c_str() : nullptr;
|
||||||
|
}
|
||||||
|
float dtsRuntimeGetAnimationDuration(DtsRuntimeHandle handle, uint32_t index) {
|
||||||
|
auto* value = context(handle);
|
||||||
|
return value && index < value->animationDurations.size() ? value->animationDurations[index] : 0.0F;
|
||||||
|
}
|
||||||
|
const char* dtsRuntimeGetSkinName(DtsRuntimeHandle handle, uint32_t index) {
|
||||||
|
auto* value = context(handle);
|
||||||
|
return value && index < value->skinNames.size() ? value->skinNames[index].c_str() : nullptr;
|
||||||
|
}
|
||||||
|
const char* dtsRuntimeGetLastError(DtsRuntimeHandle handle) {
|
||||||
|
auto* value = context(handle);
|
||||||
|
return value ? value->error.c_str() : "Invalid runtime handle.";
|
||||||
|
}
|
||||||
|
int32_t dtsRuntimeSetAnimation(DtsRuntimeHandle handle, const char* name, int32_t loop) {
|
||||||
|
auto* value = context(handle);
|
||||||
|
if (!value || !value->animationState || !name) return -1;
|
||||||
|
if (!value->skeletonData->findAnimation(name)) {
|
||||||
|
value->error = "Animation was not found.";
|
||||||
|
return -2;
|
||||||
|
}
|
||||||
|
value->skeleton->setToSetupPose();
|
||||||
|
value->animationState->setAnimation(0, name, loop != 0);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
int32_t dtsRuntimeUpdate(DtsRuntimeHandle handle, float seconds) {
|
||||||
|
auto* value = context(handle);
|
||||||
|
if (!value || !value->animationState || !value->skeleton) return -1;
|
||||||
|
value->animationState->update(std::max(0.0F, seconds));
|
||||||
|
value->animationState->apply(*value->skeleton);
|
||||||
|
value->skeleton->updateWorldTransform();
|
||||||
|
collectDrawCommands(*value);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
uint32_t dtsRuntimeGetDrawCommandCount(DtsRuntimeHandle handle) {
|
||||||
|
auto* value = context(handle);
|
||||||
|
return value ? static_cast<uint32_t>(value->renderBatches.size()) : 0;
|
||||||
|
}
|
||||||
|
int32_t dtsRuntimeGetDrawCommand(DtsRuntimeHandle handle, uint32_t index, DtsDrawCommand* command) {
|
||||||
|
auto* value = context(handle);
|
||||||
|
if (!value || index >= value->renderBatches.size() || !command
|
||||||
|
|| command->structSize < sizeof(DtsDrawCommand)) return -1;
|
||||||
|
const auto& batch = value->renderBatches[index];
|
||||||
|
command->vertices = batch.vertices.data();
|
||||||
|
command->vertexCount = static_cast<uint32_t>(batch.vertices.size());
|
||||||
|
command->indices = batch.indices.data();
|
||||||
|
command->indexCount = static_cast<uint32_t>(batch.indices.size());
|
||||||
|
command->texturePathUtf8 = batch.texturePath.c_str();
|
||||||
|
command->blendMode = batch.blendMode;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
} // extern "C"
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
#define DTS_SPINE_LINE "3.8"
|
||||||
|
#include "../common/LegacyRuntimeAdapter.inl"
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
#define DTS_SPINE_LINE "4.0"
|
||||||
|
#include "../common/LegacyRuntimeAdapter.inl"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
#define DTS_SPINE_LINE "4.1"
|
||||||
|
#define DTS_SPINE_41
|
||||||
|
#include "../common/LegacyRuntimeAdapter.inl"
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
#include "RuntimeApi.h"
|
||||||
|
|
||||||
|
#include <spine/Animation.h>
|
||||||
|
#include <spine/AnimationState.h>
|
||||||
|
#include <spine/AnimationStateData.h>
|
||||||
|
#include <spine/Atlas.h>
|
||||||
|
#include <spine/Extension.h>
|
||||||
|
#include <spine/Skeleton.h>
|
||||||
|
#include <spine/SkeletonBinary.h>
|
||||||
|
#include <spine/SkeletonData.h>
|
||||||
|
#include <spine/SkeletonJson.h>
|
||||||
|
#include <spine/SkeletonRenderer.h>
|
||||||
|
#include <spine/Skin.h>
|
||||||
|
#include <spine/TextureLoader.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cctype>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
struct TextureRecord { std::string path; };
|
||||||
|
class MetadataTextureLoader final : public spine::TextureLoader {
|
||||||
|
public:
|
||||||
|
void load(spine::AtlasPage& page, const spine::String& path) override {
|
||||||
|
page.texture = new TextureRecord{path.buffer()};
|
||||||
|
}
|
||||||
|
void unload(void* texture) override { delete static_cast<TextureRecord*>(texture); }
|
||||||
|
};
|
||||||
|
struct RenderBatch {
|
||||||
|
std::vector<DtsRenderVertex> vertices;
|
||||||
|
std::vector<std::uint16_t> indices;
|
||||||
|
std::string texturePath;
|
||||||
|
std::uint32_t blendMode = 0;
|
||||||
|
};
|
||||||
|
struct RuntimeContext {
|
||||||
|
MetadataTextureLoader textureLoader;
|
||||||
|
std::unique_ptr<spine::Atlas> atlas;
|
||||||
|
std::unique_ptr<spine::SkeletonData> skeletonData;
|
||||||
|
std::unique_ptr<spine::Skeleton> skeleton;
|
||||||
|
std::unique_ptr<spine::AnimationStateData> animationStateData;
|
||||||
|
std::unique_ptr<spine::AnimationState> animationState;
|
||||||
|
std::unique_ptr<spine::SkeletonRenderer> renderer;
|
||||||
|
std::vector<std::string> animationNames;
|
||||||
|
std::vector<float> animationDurations;
|
||||||
|
std::vector<std::string> skinNames;
|
||||||
|
std::vector<RenderBatch> renderBatches;
|
||||||
|
std::string error;
|
||||||
|
};
|
||||||
|
RuntimeContext* context(DtsRuntimeHandle handle) { return static_cast<RuntimeContext*>(handle); }
|
||||||
|
std::string lowercaseExtension(const std::string& path) {
|
||||||
|
const auto dot = path.find_last_of('.');
|
||||||
|
auto extension = dot == std::string::npos ? std::string{} : path.substr(dot);
|
||||||
|
std::transform(extension.begin(), extension.end(), extension.begin(),
|
||||||
|
[](unsigned char value) { return static_cast<char>(std::tolower(value)); });
|
||||||
|
return extension;
|
||||||
|
}
|
||||||
|
void collectMetadata(RuntimeContext& value) {
|
||||||
|
value.animationNames.clear();
|
||||||
|
value.animationDurations.clear();
|
||||||
|
auto& animations = value.skeletonData->getAnimations();
|
||||||
|
for (std::size_t index = 0; index < animations.size(); ++index) {
|
||||||
|
value.animationNames.emplace_back(animations[index]->getName().buffer());
|
||||||
|
value.animationDurations.push_back(animations[index]->getDuration());
|
||||||
|
}
|
||||||
|
value.skinNames.clear();
|
||||||
|
auto& skins = value.skeletonData->getSkins();
|
||||||
|
for (std::size_t index = 0; index < skins.size(); ++index)
|
||||||
|
value.skinNames.emplace_back(skins[index]->getName().buffer());
|
||||||
|
}
|
||||||
|
void collectDrawCommands(RuntimeContext& value) {
|
||||||
|
value.renderBatches.clear();
|
||||||
|
for (auto* command = value.renderer->render(*value.skeleton); command; command = command->next) {
|
||||||
|
RenderBatch batch;
|
||||||
|
batch.blendMode = static_cast<std::uint32_t>(command->blendMode);
|
||||||
|
if (const auto* texture = static_cast<TextureRecord*>(command->texture))
|
||||||
|
batch.texturePath = texture->path;
|
||||||
|
for (int index = 0; index < command->numVertices; ++index)
|
||||||
|
batch.vertices.push_back({command->positions[index * 2], -command->positions[index * 2 + 1],
|
||||||
|
command->uvs[index * 2], command->uvs[index * 2 + 1], command->colors[index]});
|
||||||
|
batch.indices.assign(command->indices, command->indices + command->numIndices);
|
||||||
|
value.renderBatches.push_back(std::move(batch));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
namespace spine {
|
||||||
|
SpineExtension* getDefaultExtension() { return new DefaultSpineExtension(); }
|
||||||
|
} // namespace spine
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
uint32_t dtsRuntimeGetApiVersion() { return DTS_RUNTIME_API_VERSION; }
|
||||||
|
int32_t dtsRuntimeGetInfo(DtsRuntimeInfo* info) {
|
||||||
|
if (!info || info->structSize < sizeof(DtsRuntimeInfo)) return -1;
|
||||||
|
info->apiVersion = DTS_RUNTIME_API_VERSION;
|
||||||
|
info->spineVersionLine = "4.2";
|
||||||
|
info->adapterBuild = "preview-1";
|
||||||
|
info->capabilities = DTS_RUNTIME_CAP_SKELETON_METADATA | DTS_RUNTIME_CAP_DRAW_COMMANDS;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
DtsRuntimeHandle dtsRuntimeCreate() { return new RuntimeContext; }
|
||||||
|
void dtsRuntimeDestroy(DtsRuntimeHandle handle) { delete context(handle); }
|
||||||
|
int32_t dtsRuntimeLoad(DtsRuntimeHandle handle, const DtsLoadRequest* request) {
|
||||||
|
auto* value = context(handle);
|
||||||
|
if (!value || !request || request->structSize < sizeof(DtsLoadRequest)
|
||||||
|
|| !request->skeletonPathUtf8 || !request->atlasPathUtf8) return -1;
|
||||||
|
value->error.clear();
|
||||||
|
value->renderBatches.clear();
|
||||||
|
value->renderer.reset();
|
||||||
|
value->animationState.reset();
|
||||||
|
value->animationStateData.reset();
|
||||||
|
value->skeleton.reset();
|
||||||
|
value->skeletonData.reset();
|
||||||
|
value->atlas.reset();
|
||||||
|
value->atlas = std::make_unique<spine::Atlas>(request->atlasPathUtf8, &value->textureLoader, true);
|
||||||
|
const float scale = request->scale > 0 ? request->scale : 1.0F;
|
||||||
|
if (lowercaseExtension(request->skeletonPathUtf8) == ".json") {
|
||||||
|
spine::SkeletonJson loader(value->atlas.get());
|
||||||
|
loader.setScale(scale);
|
||||||
|
value->skeletonData.reset(loader.readSkeletonDataFile(request->skeletonPathUtf8));
|
||||||
|
if (!value->skeletonData) value->error = loader.getError().buffer();
|
||||||
|
} else {
|
||||||
|
spine::SkeletonBinary loader(value->atlas.get());
|
||||||
|
loader.setScale(scale);
|
||||||
|
value->skeletonData.reset(loader.readSkeletonDataFile(request->skeletonPathUtf8));
|
||||||
|
if (!value->skeletonData) value->error = loader.getError().buffer();
|
||||||
|
}
|
||||||
|
if (!value->skeletonData) {
|
||||||
|
if (value->error.empty()) value->error = "Spine Runtime could not load skeleton data.";
|
||||||
|
return -2;
|
||||||
|
}
|
||||||
|
collectMetadata(*value);
|
||||||
|
value->skeleton = std::make_unique<spine::Skeleton>(value->skeletonData.get());
|
||||||
|
value->animationStateData = std::make_unique<spine::AnimationStateData>(value->skeletonData.get());
|
||||||
|
value->animationState = std::make_unique<spine::AnimationState>(value->animationStateData.get());
|
||||||
|
value->renderer = std::make_unique<spine::SkeletonRenderer>();
|
||||||
|
if (!value->animationNames.empty())
|
||||||
|
value->animationState->setAnimation(0, value->animationNames.front().c_str(), true);
|
||||||
|
value->animationState->apply(*value->skeleton);
|
||||||
|
value->skeleton->updateWorldTransform(spine::Physics_Update);
|
||||||
|
collectDrawCommands(*value);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
int32_t dtsRuntimeGetSkeletonInfo(DtsRuntimeHandle handle, DtsSkeletonInfo* info) {
|
||||||
|
auto* value = context(handle);
|
||||||
|
if (!value || !value->skeletonData || !info || info->structSize < sizeof(DtsSkeletonInfo)) return -1;
|
||||||
|
auto* data = value->skeletonData.get();
|
||||||
|
info->boneCount = static_cast<uint32_t>(data->getBones().size());
|
||||||
|
info->slotCount = static_cast<uint32_t>(data->getSlots().size());
|
||||||
|
info->skinCount = static_cast<uint32_t>(value->skinNames.size());
|
||||||
|
info->animationCount = static_cast<uint32_t>(value->animationNames.size());
|
||||||
|
info->eventCount = static_cast<uint32_t>(data->getEvents().size());
|
||||||
|
info->constraintCount = static_cast<uint32_t>(data->getIkConstraints().size()
|
||||||
|
+ data->getTransformConstraints().size() + data->getPathConstraints().size()
|
||||||
|
+ data->getPhysicsConstraints().size());
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const char* dtsRuntimeGetAnimationName(DtsRuntimeHandle handle, uint32_t index) {
|
||||||
|
auto* value = context(handle);
|
||||||
|
return value && index < value->animationNames.size() ? value->animationNames[index].c_str() : nullptr;
|
||||||
|
}
|
||||||
|
float dtsRuntimeGetAnimationDuration(DtsRuntimeHandle handle, uint32_t index) {
|
||||||
|
auto* value = context(handle);
|
||||||
|
return value && index < value->animationDurations.size() ? value->animationDurations[index] : 0.0F;
|
||||||
|
}
|
||||||
|
const char* dtsRuntimeGetSkinName(DtsRuntimeHandle handle, uint32_t index) {
|
||||||
|
auto* value = context(handle);
|
||||||
|
return value && index < value->skinNames.size() ? value->skinNames[index].c_str() : nullptr;
|
||||||
|
}
|
||||||
|
const char* dtsRuntimeGetLastError(DtsRuntimeHandle handle) {
|
||||||
|
auto* value = context(handle);
|
||||||
|
return value ? value->error.c_str() : "Invalid runtime handle.";
|
||||||
|
}
|
||||||
|
int32_t dtsRuntimeSetAnimation(DtsRuntimeHandle handle, const char* name, int32_t loop) {
|
||||||
|
auto* value = context(handle);
|
||||||
|
if (!value || !value->animationState || !name) return -1;
|
||||||
|
if (!value->skeletonData->findAnimation(name)) {
|
||||||
|
value->error = "Animation was not found.";
|
||||||
|
return -2;
|
||||||
|
}
|
||||||
|
value->skeleton->setToSetupPose();
|
||||||
|
value->animationState->setAnimation(0, name, loop != 0);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
int32_t dtsRuntimeUpdate(DtsRuntimeHandle handle, float seconds) {
|
||||||
|
auto* value = context(handle);
|
||||||
|
if (!value || !value->animationState || !value->skeleton || !value->renderer) return -1;
|
||||||
|
value->animationState->update(std::max(0.0F, seconds));
|
||||||
|
value->animationState->apply(*value->skeleton);
|
||||||
|
value->skeleton->updateWorldTransform(spine::Physics_Update);
|
||||||
|
collectDrawCommands(*value);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
uint32_t dtsRuntimeGetDrawCommandCount(DtsRuntimeHandle handle) {
|
||||||
|
auto* value = context(handle);
|
||||||
|
return value ? static_cast<uint32_t>(value->renderBatches.size()) : 0;
|
||||||
|
}
|
||||||
|
int32_t dtsRuntimeGetDrawCommand(DtsRuntimeHandle handle, uint32_t index, DtsDrawCommand* command) {
|
||||||
|
auto* value = context(handle);
|
||||||
|
if (!value || index >= value->renderBatches.size() || !command
|
||||||
|
|| command->structSize < sizeof(DtsDrawCommand)) return -1;
|
||||||
|
const auto& batch = value->renderBatches[index];
|
||||||
|
command->vertices = batch.vertices.data();
|
||||||
|
command->vertexCount = static_cast<uint32_t>(batch.vertices.size());
|
||||||
|
command->indices = batch.indices.data();
|
||||||
|
command->indexCount = static_cast<uint32_t>(batch.indices.size());
|
||||||
|
command->texturePathUtf8 = batch.texturePath.c_str();
|
||||||
|
command->blendMode = batch.blendMode;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
} // extern "C"
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
#include "RuntimeApi.h"
|
||||||
|
|
||||||
|
#include <spine/Animation.h>
|
||||||
|
#include <spine/AnimationState.h>
|
||||||
|
#include <spine/AnimationStateData.h>
|
||||||
|
#include <spine/Atlas.h>
|
||||||
|
#include <spine/Extension.h>
|
||||||
|
#include <spine/Skeleton.h>
|
||||||
|
#include <spine/SkeletonBinary.h>
|
||||||
|
#include <spine/SkeletonData.h>
|
||||||
|
#include <spine/SkeletonJson.h>
|
||||||
|
#include <spine/SkeletonRenderer.h>
|
||||||
|
#include <spine/Skin.h>
|
||||||
|
#include <spine/TextureLoader.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cctype>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
struct TextureRecord {
|
||||||
|
std::string path;
|
||||||
|
};
|
||||||
|
|
||||||
|
class MetadataTextureLoader final : public spine::TextureLoader {
|
||||||
|
public:
|
||||||
|
void load(spine::AtlasPage& page, const spine::String& path) override {
|
||||||
|
page.texture = new TextureRecord{path.buffer()};
|
||||||
|
}
|
||||||
|
void unload(void* texture) override { delete static_cast<TextureRecord*>(texture); }
|
||||||
|
};
|
||||||
|
|
||||||
|
struct RenderBatch {
|
||||||
|
std::vector<DtsRenderVertex> vertices;
|
||||||
|
std::vector<std::uint16_t> indices;
|
||||||
|
std::string texturePath;
|
||||||
|
std::uint32_t blendMode = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct RuntimeContext {
|
||||||
|
MetadataTextureLoader textureLoader;
|
||||||
|
std::unique_ptr<spine::Atlas> atlas;
|
||||||
|
std::unique_ptr<spine::SkeletonData> skeletonData;
|
||||||
|
std::unique_ptr<spine::Skeleton> skeleton;
|
||||||
|
std::unique_ptr<spine::AnimationStateData> animationStateData;
|
||||||
|
std::unique_ptr<spine::AnimationState> animationState;
|
||||||
|
std::unique_ptr<spine::SkeletonRenderer> renderer;
|
||||||
|
std::vector<std::string> animationNames;
|
||||||
|
std::vector<float> animationDurations;
|
||||||
|
std::vector<std::string> skinNames;
|
||||||
|
std::vector<RenderBatch> renderBatches;
|
||||||
|
std::string error;
|
||||||
|
};
|
||||||
|
|
||||||
|
RuntimeContext* context(DtsRuntimeHandle handle) {
|
||||||
|
return static_cast<RuntimeContext*>(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string lowercaseExtension(const std::string& path) {
|
||||||
|
const auto dot = path.find_last_of('.');
|
||||||
|
auto extension = dot == std::string::npos ? std::string{} : path.substr(dot);
|
||||||
|
std::transform(extension.begin(), extension.end(), extension.begin(),
|
||||||
|
[](unsigned char value) { return static_cast<char>(std::tolower(value)); });
|
||||||
|
return extension;
|
||||||
|
}
|
||||||
|
|
||||||
|
void collectMetadata(RuntimeContext& value) {
|
||||||
|
value.animationNames.clear();
|
||||||
|
value.animationDurations.clear();
|
||||||
|
auto& animations = value.skeletonData->getAnimations();
|
||||||
|
for (std::size_t index = 0; index < animations.size(); ++index) {
|
||||||
|
auto* animation = animations[index];
|
||||||
|
value.animationNames.emplace_back(animation->getName().buffer());
|
||||||
|
value.animationDurations.push_back(animation->getDuration());
|
||||||
|
}
|
||||||
|
value.skinNames.clear();
|
||||||
|
auto& skins = value.skeletonData->getSkins();
|
||||||
|
for (std::size_t index = 0; index < skins.size(); ++index) {
|
||||||
|
auto* skin = skins[index];
|
||||||
|
value.skinNames.emplace_back(skin->getName().buffer());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void collectDrawCommands(RuntimeContext& value) {
|
||||||
|
value.renderBatches.clear();
|
||||||
|
for (auto* command = value.renderer->render(*value.skeleton); command;
|
||||||
|
command = command->next) {
|
||||||
|
RenderBatch batch;
|
||||||
|
batch.blendMode = static_cast<std::uint32_t>(command->blendMode);
|
||||||
|
if (const auto* texture = static_cast<TextureRecord*>(command->texture))
|
||||||
|
batch.texturePath = texture->path;
|
||||||
|
batch.vertices.reserve(static_cast<std::size_t>(command->numVertices));
|
||||||
|
for (int index = 0; index < command->numVertices; ++index) {
|
||||||
|
batch.vertices.push_back({
|
||||||
|
command->positions[index * 2], command->positions[index * 2 + 1],
|
||||||
|
command->uvs[index * 2], command->uvs[index * 2 + 1],
|
||||||
|
command->colors[index]});
|
||||||
|
}
|
||||||
|
batch.indices.assign(command->indices, command->indices + command->numIndices);
|
||||||
|
value.renderBatches.push_back(std::move(batch));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
namespace spine {
|
||||||
|
|
||||||
|
SpineExtension* getDefaultExtension() {
|
||||||
|
return new DefaultSpineExtension();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace spine
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
|
||||||
|
uint32_t dtsRuntimeGetApiVersion() {
|
||||||
|
return DTS_RUNTIME_API_VERSION;
|
||||||
|
}
|
||||||
|
|
||||||
|
int32_t dtsRuntimeGetInfo(DtsRuntimeInfo* info) {
|
||||||
|
if (!info || info->structSize < sizeof(DtsRuntimeInfo)) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
info->apiVersion = DTS_RUNTIME_API_VERSION;
|
||||||
|
info->spineVersionLine = "4.3";
|
||||||
|
info->adapterBuild = "prototype-2";
|
||||||
|
info->capabilities = DTS_RUNTIME_CAP_SKELETON_METADATA | DTS_RUNTIME_CAP_DRAW_COMMANDS;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
DtsRuntimeHandle dtsRuntimeCreate() {
|
||||||
|
return new RuntimeContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
void dtsRuntimeDestroy(DtsRuntimeHandle handle) {
|
||||||
|
delete context(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
int32_t dtsRuntimeLoad(DtsRuntimeHandle handle, const DtsLoadRequest* request) {
|
||||||
|
auto* value = context(handle);
|
||||||
|
if (!value || !request || request->structSize < sizeof(DtsLoadRequest)
|
||||||
|
|| !request->skeletonPathUtf8 || !request->atlasPathUtf8) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
value->error.clear();
|
||||||
|
value->renderBatches.clear();
|
||||||
|
value->renderer.reset();
|
||||||
|
value->animationState.reset();
|
||||||
|
value->animationStateData.reset();
|
||||||
|
value->skeleton.reset();
|
||||||
|
value->skeletonData.reset();
|
||||||
|
value->atlas.reset();
|
||||||
|
value->atlas = std::make_unique<spine::Atlas>(
|
||||||
|
request->atlasPathUtf8, &value->textureLoader, true);
|
||||||
|
|
||||||
|
const float scale = request->scale > 0 ? request->scale : 1.0F;
|
||||||
|
if (lowercaseExtension(request->skeletonPathUtf8) == ".json") {
|
||||||
|
spine::SkeletonJson loader(*value->atlas);
|
||||||
|
loader.setScale(scale);
|
||||||
|
value->skeletonData.reset(loader.readSkeletonDataFile(request->skeletonPathUtf8));
|
||||||
|
if (!value->skeletonData) value->error = loader.getError().buffer();
|
||||||
|
} else {
|
||||||
|
spine::SkeletonBinary loader(*value->atlas);
|
||||||
|
loader.setScale(scale);
|
||||||
|
value->skeletonData.reset(loader.readSkeletonDataFile(request->skeletonPathUtf8));
|
||||||
|
if (!value->skeletonData) value->error = loader.getError().buffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value->skeletonData) {
|
||||||
|
if (value->error.empty()) value->error = "Spine Runtime could not load skeleton data.";
|
||||||
|
return -2;
|
||||||
|
}
|
||||||
|
collectMetadata(*value);
|
||||||
|
value->skeleton = std::make_unique<spine::Skeleton>(*value->skeletonData);
|
||||||
|
value->animationStateData = std::make_unique<spine::AnimationStateData>(
|
||||||
|
*value->skeletonData);
|
||||||
|
value->animationState = std::make_unique<spine::AnimationState>(
|
||||||
|
*value->animationStateData);
|
||||||
|
value->renderer = std::make_unique<spine::SkeletonRenderer>();
|
||||||
|
if (!value->animationNames.empty())
|
||||||
|
value->animationState->setAnimation(0, value->animationNames.front().c_str(), true);
|
||||||
|
value->animationState->apply(*value->skeleton);
|
||||||
|
value->skeleton->updateWorldTransform(spine::Physics_Update);
|
||||||
|
collectDrawCommands(*value);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int32_t dtsRuntimeGetSkeletonInfo(DtsRuntimeHandle handle, DtsSkeletonInfo* info) {
|
||||||
|
auto* value = context(handle);
|
||||||
|
if (!value || !value->skeletonData || !info || info->structSize < sizeof(DtsSkeletonInfo)) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
info->boneCount = static_cast<uint32_t>(value->skeletonData->getBones().size());
|
||||||
|
info->slotCount = static_cast<uint32_t>(value->skeletonData->getSlots().size());
|
||||||
|
info->skinCount = static_cast<uint32_t>(value->skinNames.size());
|
||||||
|
info->animationCount = static_cast<uint32_t>(value->animationNames.size());
|
||||||
|
info->eventCount = static_cast<uint32_t>(value->skeletonData->getEvents().size());
|
||||||
|
info->constraintCount = static_cast<uint32_t>(value->skeletonData->getConstraints().size());
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* dtsRuntimeGetAnimationName(DtsRuntimeHandle handle, uint32_t index) {
|
||||||
|
auto* value = context(handle);
|
||||||
|
return value && index < value->animationNames.size()
|
||||||
|
? value->animationNames[index].c_str() : nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
float dtsRuntimeGetAnimationDuration(DtsRuntimeHandle handle, uint32_t index) {
|
||||||
|
auto* value = context(handle);
|
||||||
|
return value && index < value->animationDurations.size()
|
||||||
|
? value->animationDurations[index] : 0.0F;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* dtsRuntimeGetSkinName(DtsRuntimeHandle handle, uint32_t index) {
|
||||||
|
auto* value = context(handle);
|
||||||
|
return value && index < value->skinNames.size() ? value->skinNames[index].c_str() : nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* dtsRuntimeGetLastError(DtsRuntimeHandle handle) {
|
||||||
|
auto* value = context(handle);
|
||||||
|
return value ? value->error.c_str() : "Invalid runtime handle.";
|
||||||
|
}
|
||||||
|
|
||||||
|
int32_t dtsRuntimeSetAnimation(
|
||||||
|
DtsRuntimeHandle handle, const char* animationNameUtf8, int32_t loop) {
|
||||||
|
auto* value = context(handle);
|
||||||
|
if (!value || !value->animationState || !animationNameUtf8) return -1;
|
||||||
|
if (!value->skeletonData->findAnimation(animationNameUtf8)) {
|
||||||
|
value->error = "Animation was not found.";
|
||||||
|
return -2;
|
||||||
|
}
|
||||||
|
value->skeleton->setupPose();
|
||||||
|
value->animationState->setAnimation(0, animationNameUtf8, loop != 0);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int32_t dtsRuntimeUpdate(DtsRuntimeHandle handle, float deltaSeconds) {
|
||||||
|
auto* value = context(handle);
|
||||||
|
if (!value || !value->animationState || !value->skeleton || !value->renderer) return -1;
|
||||||
|
value->animationState->update(std::max(0.0F, deltaSeconds));
|
||||||
|
value->animationState->apply(*value->skeleton);
|
||||||
|
value->skeleton->updateWorldTransform(spine::Physics_Update);
|
||||||
|
collectDrawCommands(*value);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t dtsRuntimeGetDrawCommandCount(DtsRuntimeHandle handle) {
|
||||||
|
auto* value = context(handle);
|
||||||
|
return value ? static_cast<uint32_t>(value->renderBatches.size()) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int32_t dtsRuntimeGetDrawCommand(
|
||||||
|
DtsRuntimeHandle handle, uint32_t index, DtsDrawCommand* command) {
|
||||||
|
auto* value = context(handle);
|
||||||
|
if (!value || index >= value->renderBatches.size() || !command
|
||||||
|
|| command->structSize < sizeof(DtsDrawCommand)) return -1;
|
||||||
|
const auto& batch = value->renderBatches[index];
|
||||||
|
command->vertices = batch.vertices.data();
|
||||||
|
command->vertexCount = static_cast<uint32_t>(batch.vertices.size());
|
||||||
|
command->indices = batch.indices.data();
|
||||||
|
command->indexCount = static_cast<uint32_t>(batch.indices.size());
|
||||||
|
command->texturePathUtf8 = batch.texturePath.c_str();
|
||||||
|
command->blendMode = batch.blendMode;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // extern "C"
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
#include "editor/SpineEditorLocator.hpp"
|
||||||
|
#include "editor/SpineProbe.hpp"
|
||||||
|
#include "platform/ProcessRunner.hpp"
|
||||||
|
#include "scan/SpineDataVersionDetector.hpp"
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <iostream>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
void printProbe(const dts::EditorProbeResult& result) {
|
||||||
|
std::cout << "path: " << result.executable.string() << '\n';
|
||||||
|
std::cout << "status: " << dts::toString(result.status) << '\n';
|
||||||
|
if (result.launcherVersion) {
|
||||||
|
std::cout << "launcher: " << result.launcherVersion->toString() << '\n';
|
||||||
|
}
|
||||||
|
if (result.editorVersion) {
|
||||||
|
std::cout << "editor: " << result.editorVersion->toString() << '\n';
|
||||||
|
}
|
||||||
|
if (!result.edition.empty()) {
|
||||||
|
std::cout << "edition: " << result.edition << '\n';
|
||||||
|
}
|
||||||
|
std::cout << "message: " << result.message << '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
void printUsage() {
|
||||||
|
std::cout << "DateToSpine technical prototype\n"
|
||||||
|
<< "Usage:\n"
|
||||||
|
<< " datetospine-cli detect\n"
|
||||||
|
<< " datetospine-cli probe <Spine.app|executable> [timeout-ms]\n"
|
||||||
|
<< " datetospine-cli inspect-data <skeleton.json|skeleton.skel>\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main(int argc, char** argv) {
|
||||||
|
if (argc < 2) {
|
||||||
|
printUsage();
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string command = argv[1];
|
||||||
|
dts::SpineEditorLocator locator;
|
||||||
|
if (command == "detect") {
|
||||||
|
const auto candidates = locator.discover();
|
||||||
|
for (const auto& candidate : candidates) {
|
||||||
|
std::cout << candidate.string() << '\n';
|
||||||
|
}
|
||||||
|
return candidates.empty() ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command == "probe" && argc >= 3) {
|
||||||
|
const auto executable = locator.normalize(std::filesystem::path(argv[2]));
|
||||||
|
if (!executable) {
|
||||||
|
std::cerr << "The supplied path does not contain a Spine executable.\n";
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto timeout = std::chrono::milliseconds(10000);
|
||||||
|
if (argc >= 4) {
|
||||||
|
try {
|
||||||
|
timeout = std::chrono::milliseconds(std::stoll(argv[3]));
|
||||||
|
} catch (...) {
|
||||||
|
std::cerr << "Invalid timeout.\n";
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
auto runner = dts::createDefaultProcessRunner();
|
||||||
|
dts::SpineProbe probe(*runner);
|
||||||
|
const auto result = probe.inspect(*executable, timeout);
|
||||||
|
printProbe(result);
|
||||||
|
return result.status == dts::EditorProbeStatus::Available ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command == "inspect-data" && argc >= 3) {
|
||||||
|
const auto result = dts::SpineDataVersionDetector{}.inspect(argv[2]);
|
||||||
|
std::cout << "format: " << dts::toString(result.format) << '\n';
|
||||||
|
std::cout << "status: " << dts::toString(result.status) << '\n';
|
||||||
|
if (result.version) {
|
||||||
|
std::cout << "version: " << result.version->toString() << '\n';
|
||||||
|
}
|
||||||
|
std::cout << "message: " << result.message << '\n';
|
||||||
|
return result.status == dts::VersionDetectionStatus::Detected ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
printUsage();
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#include "ui/MainWindow.hpp"
|
||||||
|
|
||||||
|
#include <QApplication>
|
||||||
|
#include <QCoreApplication>
|
||||||
|
#include <QPixmap>
|
||||||
|
#include <QTimer>
|
||||||
|
|
||||||
|
int main(int argc, char** argv) {
|
||||||
|
QApplication application(argc, argv);
|
||||||
|
QCoreApplication::setApplicationName("DateToSpine");
|
||||||
|
QCoreApplication::setOrganizationName("DateToSpine");
|
||||||
|
|
||||||
|
dts::MainWindow window;
|
||||||
|
window.show();
|
||||||
|
const auto arguments = application.arguments();
|
||||||
|
for (int index = 1; index < arguments.size(); ++index) {
|
||||||
|
if (arguments[index] == "--screenshot") {
|
||||||
|
++index;
|
||||||
|
} else if (!arguments[index].startsWith('-')) {
|
||||||
|
window.openResourcePath(arguments[index]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const int screenshotOption = arguments.indexOf("--screenshot");
|
||||||
|
if (screenshotOption >= 0 && screenshotOption + 1 < arguments.size()) {
|
||||||
|
const auto screenshotPath = arguments[screenshotOption + 1];
|
||||||
|
QTimer::singleShot(250, &application, [&application, &window, screenshotPath] {
|
||||||
|
window.grab().save(screenshotPath);
|
||||||
|
application.quit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (application.arguments().contains("--smoke-test")) {
|
||||||
|
QTimer::singleShot(100, &application, &QCoreApplication::quit);
|
||||||
|
}
|
||||||
|
return application.exec();
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
#include "atlas/AtlasParser.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <charconv>
|
||||||
|
#include <cctype>
|
||||||
|
#include <fstream>
|
||||||
|
#include <sstream>
|
||||||
|
|
||||||
|
namespace dts {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
std::string trim(std::string value) {
|
||||||
|
const auto isSpace = [](unsigned char character) { return std::isspace(character) != 0; };
|
||||||
|
value.erase(value.begin(), std::find_if_not(value.begin(), value.end(), isSpace));
|
||||||
|
value.erase(std::find_if_not(value.rbegin(), value.rend(), isSpace).base(), value.end());
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::string> splitValues(const std::string& value) {
|
||||||
|
std::vector<std::string> values;
|
||||||
|
std::stringstream stream(value);
|
||||||
|
std::string item;
|
||||||
|
while (std::getline(stream, item, ',')) {
|
||||||
|
values.push_back(trim(item));
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<std::vector<int>> parseIntegers(const std::string& value) {
|
||||||
|
std::vector<int> integers;
|
||||||
|
for (const auto& item : splitValues(value)) {
|
||||||
|
int number = 0;
|
||||||
|
const auto result = std::from_chars(item.data(), item.data() + item.size(), number);
|
||||||
|
if (result.ec != std::errc{} || result.ptr != item.data() + item.size()) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
integers.push_back(number);
|
||||||
|
}
|
||||||
|
return integers;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isPageKey(const std::string& key) {
|
||||||
|
return key == "size" || key == "format" || key == "filter" || key == "repeat"
|
||||||
|
|| key == "pma" || key == "scale";
|
||||||
|
}
|
||||||
|
|
||||||
|
bool parseBoolean(const std::string& value) {
|
||||||
|
return value == "true" || value == "yes" || value == "1";
|
||||||
|
}
|
||||||
|
|
||||||
|
void applyPageProperty(AtlasPage& page, const std::string& key, const std::string& value) {
|
||||||
|
if (key == "size") {
|
||||||
|
page.size = parseIntegers(value).value_or(std::vector<int>{});
|
||||||
|
} else if (key == "format") {
|
||||||
|
page.format = value;
|
||||||
|
} else if (key == "filter") {
|
||||||
|
page.filter = value;
|
||||||
|
} else if (key == "repeat") {
|
||||||
|
page.repeat = value;
|
||||||
|
} else if (key == "pma") {
|
||||||
|
page.premultipliedAlpha = parseBoolean(value);
|
||||||
|
} else if (key == "scale") {
|
||||||
|
try {
|
||||||
|
page.scale = std::stod(value);
|
||||||
|
} catch (...) {
|
||||||
|
page.scale.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void applyRegionProperty(AtlasRegion& region, const std::string& key, const std::string& value) {
|
||||||
|
if (key == "rotate") {
|
||||||
|
region.rotated = parseBoolean(value) || value == "90" || value == "270";
|
||||||
|
} else if (key == "bounds") {
|
||||||
|
region.bounds = parseIntegers(value).value_or(std::vector<int>{});
|
||||||
|
} else if (key == "xy") {
|
||||||
|
region.position = parseIntegers(value).value_or(std::vector<int>{});
|
||||||
|
} else if (key == "size") {
|
||||||
|
region.size = parseIntegers(value).value_or(std::vector<int>{});
|
||||||
|
} else if (key == "orig") {
|
||||||
|
region.originalSize = parseIntegers(value).value_or(std::vector<int>{});
|
||||||
|
} else if (key == "offset" || key == "offsets") {
|
||||||
|
region.offsets = parseIntegers(value).value_or(std::vector<int>{});
|
||||||
|
} else if (key == "split") {
|
||||||
|
region.split = parseIntegers(value).value_or(std::vector<int>{});
|
||||||
|
} else if (key == "pad") {
|
||||||
|
region.pad = parseIntegers(value).value_or(std::vector<int>{});
|
||||||
|
} else if (key == "index") {
|
||||||
|
const auto values = parseIntegers(value);
|
||||||
|
if (values && !values->empty()) {
|
||||||
|
region.index = values->front();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
AtlasParseResult AtlasParser::parseFile(const std::filesystem::path& path) const {
|
||||||
|
std::ifstream input(path, std::ios::binary);
|
||||||
|
if (!input) {
|
||||||
|
return {std::nullopt, "Atlas file could not be opened.", 0};
|
||||||
|
}
|
||||||
|
std::ostringstream contents;
|
||||||
|
contents << input.rdbuf();
|
||||||
|
return parseText(contents.str());
|
||||||
|
}
|
||||||
|
|
||||||
|
AtlasParseResult AtlasParser::parseText(const std::string& text) const {
|
||||||
|
AtlasDocument document;
|
||||||
|
AtlasPage* page = nullptr;
|
||||||
|
AtlasRegion* region = nullptr;
|
||||||
|
bool expectPage = true;
|
||||||
|
std::istringstream input(text);
|
||||||
|
std::string rawLine;
|
||||||
|
std::size_t lineNumber = 0;
|
||||||
|
|
||||||
|
while (std::getline(input, rawLine)) {
|
||||||
|
++lineNumber;
|
||||||
|
if (!rawLine.empty() && rawLine.back() == '\r') {
|
||||||
|
rawLine.pop_back();
|
||||||
|
}
|
||||||
|
const auto line = trim(rawLine);
|
||||||
|
if (line.empty()) {
|
||||||
|
expectPage = true;
|
||||||
|
page = nullptr;
|
||||||
|
region = nullptr;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto colon = line.find(':');
|
||||||
|
if (colon == std::string::npos) {
|
||||||
|
if (expectPage || page == nullptr) {
|
||||||
|
AtlasPage nextPage;
|
||||||
|
nextPage.imagePath = std::filesystem::path(line);
|
||||||
|
document.pages.push_back(std::move(nextPage));
|
||||||
|
page = &document.pages.back();
|
||||||
|
region = nullptr;
|
||||||
|
expectPage = false;
|
||||||
|
} else {
|
||||||
|
AtlasRegion nextRegion;
|
||||||
|
nextRegion.name = line;
|
||||||
|
page->regions.push_back(std::move(nextRegion));
|
||||||
|
region = &page->regions.back();
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!page) {
|
||||||
|
return {std::nullopt, "Atlas property appears before a page name.", lineNumber};
|
||||||
|
}
|
||||||
|
const auto key = trim(line.substr(0, colon));
|
||||||
|
const auto value = trim(line.substr(colon + 1));
|
||||||
|
if (!region && isPageKey(key)) {
|
||||||
|
applyPageProperty(*page, key, value);
|
||||||
|
} else if (region) {
|
||||||
|
applyRegionProperty(*region, key, value);
|
||||||
|
} else {
|
||||||
|
document.warnings.push_back("Unknown page property at line " + std::to_string(lineNumber));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.pages.empty()) {
|
||||||
|
return {std::nullopt, "Atlas does not contain any pages.", 0};
|
||||||
|
}
|
||||||
|
for (const auto& atlasPage : document.pages) {
|
||||||
|
if (atlasPage.imagePath.empty()) {
|
||||||
|
return {std::nullopt, "Atlas contains an empty page name.", 0};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {std::move(document), {}, 0};
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace dts
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <filesystem>
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace dts {
|
||||||
|
|
||||||
|
struct AtlasRegion {
|
||||||
|
std::string name;
|
||||||
|
bool rotated = false;
|
||||||
|
std::vector<int> bounds;
|
||||||
|
std::vector<int> position;
|
||||||
|
std::vector<int> size;
|
||||||
|
std::vector<int> originalSize;
|
||||||
|
std::vector<int> offsets;
|
||||||
|
std::vector<int> split;
|
||||||
|
std::vector<int> pad;
|
||||||
|
std::optional<int> index;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct AtlasPage {
|
||||||
|
std::filesystem::path imagePath;
|
||||||
|
std::vector<int> size;
|
||||||
|
std::string format;
|
||||||
|
std::string filter;
|
||||||
|
std::string repeat;
|
||||||
|
std::optional<bool> premultipliedAlpha;
|
||||||
|
std::optional<double> scale;
|
||||||
|
std::vector<AtlasRegion> regions;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct AtlasDocument {
|
||||||
|
std::vector<AtlasPage> pages;
|
||||||
|
std::vector<std::string> warnings;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct AtlasParseResult {
|
||||||
|
std::optional<AtlasDocument> document;
|
||||||
|
std::string error;
|
||||||
|
std::size_t errorLine = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
class AtlasParser {
|
||||||
|
public:
|
||||||
|
[[nodiscard]] AtlasParseResult parseFile(const std::filesystem::path& path) const;
|
||||||
|
[[nodiscard]] AtlasParseResult parseText(const std::string& text) const;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace dts
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
#include "conversion/ConversionJob.hpp"
|
||||||
|
|
||||||
|
#include "atlas/AtlasParser.hpp"
|
||||||
|
#include "domain/SpineVersion.hpp"
|
||||||
|
#include "editor/SpineVersionAvailability.hpp"
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QDirIterator>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QProcess>
|
||||||
|
#include <QRegularExpression>
|
||||||
|
#include <QTemporaryDir>
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
#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 uniqueDirectory(const QString& parent, const QString& name) {
|
||||||
|
QDir directory(parent);
|
||||||
|
QString candidate = directory.filePath(name);
|
||||||
|
for (int suffix = 2; QFileInfo::exists(candidate); ++suffix)
|
||||||
|
candidate = directory.filePath(QStringLiteral("%1-%2").arg(name).arg(suffix));
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
ConversionJob::ConversionJob(ConversionRequest request, QObject* parent)
|
||||||
|
: QObject(parent), request_(std::move(request)) {}
|
||||||
|
|
||||||
|
ConversionJob::~ConversionJob() = default;
|
||||||
|
|
||||||
|
void ConversionJob::start() {
|
||||||
|
QString error;
|
||||||
|
if (!prepare(error)) {
|
||||||
|
fail(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startUnpack();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ConversionJob::prepare(QString& error) {
|
||||||
|
const auto version = SpineVersion::parse(request_.editorVersion.toStdString());
|
||||||
|
if (!version || !SpineVersionAvailability{}.isCached(*version)) {
|
||||||
|
error = tr("本地未缓存 Spine %1。为避免触发下载,转换已停止。")
|
||||||
|
.arg(request_.editorVersion);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!QFileInfo::exists(request_.editorPath)) {
|
||||||
|
error = tr("找不到已指定的 Spine Editor。请重新指定程序路径。");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const auto atlas = AtlasParser{}.parseFile(localPath(request_.atlasPath));
|
||||||
|
if (!atlas.document || atlas.document->pages.empty()) {
|
||||||
|
error = tr("Atlas 无法识别,不能确定官方导入比例。");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
double scale = atlas.document->pages.front().scale.value_or(1.0);
|
||||||
|
if (!std::isfinite(scale) || scale <= 0.0) {
|
||||||
|
error = tr("Atlas 中的 scale 无效。");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (const auto& page : atlas.document->pages) {
|
||||||
|
const double pageScale = page.scale.value_or(1.0);
|
||||||
|
if (!std::isfinite(pageScale) || std::abs(pageScale - scale) > 0.000001) {
|
||||||
|
error = tr("Atlas 的纹理页使用了不同 scale,无法生成尺寸一致的工程。");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
importScale_ = QString::number(scale, 'g', 15);
|
||||||
|
QDir parent(request_.outputParent);
|
||||||
|
if (!parent.exists() && !parent.mkpath(QStringLiteral("."))) {
|
||||||
|
error = tr("无法创建输出目录。");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
finalDirectory_ = uniqueDirectory(request_.outputParent, request_.assetName);
|
||||||
|
staging_ = std::make_unique<QTemporaryDir>(
|
||||||
|
parent.filePath(QStringLiteral(".datetospine-XXXXXX")));
|
||||||
|
if (!staging_->isValid()) {
|
||||||
|
error = tr("无法创建临时转换目录。");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
QDir staging(staging_->path());
|
||||||
|
if (!staging.mkpath(QStringLiteral("images"))) {
|
||||||
|
error = tr("无法准备图片目录。");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
projectPath_ = staging.filePath(request_.assetName + QStringLiteral(".spine"));
|
||||||
|
// The official unpacker reads `pma` from the Atlas. When conversion to
|
||||||
|
// straight alpha is requested, pass the source Atlas unchanged so Spine
|
||||||
|
// performs the conversion itself. Otherwise a temporary metadata copy
|
||||||
|
// disables that conversion while leaving all geometry fields intact.
|
||||||
|
unpackAtlasPath_ = request_.unpremultiplyAlpha
|
||||||
|
? request_.atlasPath
|
||||||
|
: staging.filePath(QStringLiteral("unpack.atlas"));
|
||||||
|
return writeUnpackAtlas(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ConversionJob::writeUnpackAtlas(QString& error) {
|
||||||
|
if (request_.unpremultiplyAlpha) return true;
|
||||||
|
|
||||||
|
QFile source(request_.atlasPath);
|
||||||
|
if (!source.open(QIODevice::ReadOnly)) {
|
||||||
|
error = tr("无法读取 Atlas 文件。");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
auto contents = QString::fromUtf8(source.readAll());
|
||||||
|
source.close();
|
||||||
|
// Preserve packed pixel values when the user explicitly leaves PMA enabled.
|
||||||
|
// Pixel decoding, cropping and Alpha handling remain entirely inside Spine.
|
||||||
|
contents.replace(QRegularExpression(
|
||||||
|
QStringLiteral("(?m)^(\\s*pma\\s*:\\s*)true(\\s*)$")), QStringLiteral("\\1false\\2"));
|
||||||
|
QFile temporary(unpackAtlasPath_);
|
||||||
|
const auto encoded = contents.toUtf8();
|
||||||
|
if (!temporary.open(QIODevice::WriteOnly | QIODevice::Truncate)
|
||||||
|
|| temporary.write(encoded) != encoded.size()) {
|
||||||
|
error = tr("无法准备离线图集解包配置。");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ConversionJob::startUnpack() {
|
||||||
|
stage_ = Stage::Unpacking;
|
||||||
|
process_ = new QProcess(this);
|
||||||
|
process_->setProcessChannelMode(QProcess::MergedChannels);
|
||||||
|
connect(process_, &QProcess::finished, this, &ConversionJob::processFinished);
|
||||||
|
connect(process_, &QProcess::errorOccurred, this, [this](QProcess::ProcessError error) {
|
||||||
|
if (error == QProcess::FailedToStart && stage_ != Stage::Done)
|
||||||
|
fail(tr("无法启动本机 Spine Editor。"));
|
||||||
|
});
|
||||||
|
process_->start(request_.editorPath, {
|
||||||
|
"-u", request_.editorVersion,
|
||||||
|
"-i", QFileInfo(request_.atlasPath).absolutePath(),
|
||||||
|
"-o", QDir(staging_->path()).filePath(QStringLiteral("images")),
|
||||||
|
"-c", unpackAtlasPath_});
|
||||||
|
}
|
||||||
|
|
||||||
|
void ConversionJob::startImport() {
|
||||||
|
stage_ = Stage::Importing;
|
||||||
|
process_->start(request_.editorPath, {
|
||||||
|
"-u", request_.editorVersion,
|
||||||
|
"-i", request_.skeletonPath,
|
||||||
|
// Numeric scale works with all supported Editor lines. Older Editors
|
||||||
|
// (notably 4.0) do not accept an Atlas path for this argument.
|
||||||
|
"-s", importScale_,
|
||||||
|
"-o", projectPath_,
|
||||||
|
"--import"});
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ConversionJob::hasUnpackedImages() const {
|
||||||
|
QDirIterator iterator(QDir(staging_->path()).filePath(QStringLiteral("images")),
|
||||||
|
{"*.png", "*.jpg", "*.jpeg", "*.webp"}, QDir::Files,
|
||||||
|
QDirIterator::Subdirectories);
|
||||||
|
return iterator.hasNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ConversionJob::processFinished(int exitCode) {
|
||||||
|
if (stage_ == Stage::Done) return;
|
||||||
|
const auto output = QString::fromUtf8(process_->readAll());
|
||||||
|
if (exitCode != 0) {
|
||||||
|
fail(stage_ == Stage::Unpacking
|
||||||
|
? tr("官方图集解包失败:\n%1").arg(output.right(1600))
|
||||||
|
: tr("Spine 工程导入失败:\n%1").arg(output.right(1600)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (stage_ == Stage::Unpacking) {
|
||||||
|
if (!hasUnpackedImages()) {
|
||||||
|
fail(tr("官方图集解包未生成任何图片。"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (unpackAtlasPath_ != request_.atlasPath) QFile::remove(unpackAtlasPath_);
|
||||||
|
startImport();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (stage_ == Stage::Importing) {
|
||||||
|
if (QFileInfo(projectPath_).size() <= 0) {
|
||||||
|
fail(tr("Spine Editor 未生成有效的 .spine 工程。"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
publish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ConversionJob::publish() {
|
||||||
|
stage_ = Stage::Publishing;
|
||||||
|
const auto source = localPath(staging_->path());
|
||||||
|
const auto destination = localPath(finalDirectory_);
|
||||||
|
std::error_code error;
|
||||||
|
std::filesystem::rename(source, destination, error);
|
||||||
|
if (error) {
|
||||||
|
fail(tr("无法发布转换结果:%1").arg(QString::fromStdString(error.message())));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
staging_->setAutoRemove(false);
|
||||||
|
stage_ = Stage::Done;
|
||||||
|
emit finished(true, finalDirectory_, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void ConversionJob::fail(const QString& reason) {
|
||||||
|
if (stage_ == Stage::Done) return;
|
||||||
|
stage_ = Stage::Done;
|
||||||
|
if (process_) process_->kill();
|
||||||
|
emit finished(false, {}, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace dts
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
class QProcess;
|
||||||
|
class QTemporaryDir;
|
||||||
|
|
||||||
|
namespace dts {
|
||||||
|
|
||||||
|
struct ConversionRequest {
|
||||||
|
QString editorPath;
|
||||||
|
QString editorVersion;
|
||||||
|
QString skeletonPath;
|
||||||
|
QString atlasPath;
|
||||||
|
QString outputParent;
|
||||||
|
QString assetName;
|
||||||
|
bool unpremultiplyAlpha = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
class ConversionJob final : public QObject {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit ConversionJob(ConversionRequest request, QObject* parent = nullptr);
|
||||||
|
~ConversionJob() override;
|
||||||
|
void start();
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void finished(bool success, const QString& outputDirectory, const QString& error);
|
||||||
|
|
||||||
|
private:
|
||||||
|
enum class Stage { Preparing, Unpacking, Importing, Publishing, Done };
|
||||||
|
|
||||||
|
bool prepare(QString& error);
|
||||||
|
bool writeUnpackAtlas(QString& error);
|
||||||
|
bool hasUnpackedImages() const;
|
||||||
|
void startUnpack();
|
||||||
|
void startImport();
|
||||||
|
void processFinished(int exitCode);
|
||||||
|
void fail(const QString& reason);
|
||||||
|
void publish();
|
||||||
|
|
||||||
|
ConversionRequest request_;
|
||||||
|
Stage stage_ = Stage::Preparing;
|
||||||
|
std::unique_ptr<QTemporaryDir> staging_;
|
||||||
|
QProcess* process_ = nullptr;
|
||||||
|
QString finalDirectory_;
|
||||||
|
QString projectPath_;
|
||||||
|
QString unpackAtlasPath_;
|
||||||
|
QString importScale_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace dts
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
#include "domain/SpineVersion.hpp"
|
||||||
|
|
||||||
|
#include <charconv>
|
||||||
|
|
||||||
|
namespace dts {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
std::optional<int> parseNumber(std::string_view text) {
|
||||||
|
int value = 0;
|
||||||
|
const auto result = std::from_chars(text.data(), text.data() + text.size(), value);
|
||||||
|
if (result.ec != std::errc{} || result.ptr != text.data() + text.size()) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::optional<SpineVersion> SpineVersion::parse(std::string_view text) {
|
||||||
|
const auto firstDot = text.find('.');
|
||||||
|
if (firstDot == std::string_view::npos) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto secondDot = text.find('.', firstDot + 1);
|
||||||
|
const auto majorValue = parseNumber(text.substr(0, firstDot));
|
||||||
|
const auto minorEnd = secondDot == std::string_view::npos ? text.size() : secondDot;
|
||||||
|
const auto minorValue = parseNumber(text.substr(firstDot + 1, minorEnd - firstDot - 1));
|
||||||
|
if (!majorValue || !minorValue) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
SpineVersion version{*majorValue, *minorValue, 0, false};
|
||||||
|
if (secondDot != std::string_view::npos) {
|
||||||
|
auto patchText = text.substr(secondDot + 1);
|
||||||
|
const auto suffix = patchText.find_first_not_of("0123456789");
|
||||||
|
const auto numericPatch = patchText.substr(0, suffix);
|
||||||
|
if (numericPatch.empty() || (suffix != std::string_view::npos
|
||||||
|
&& patchText[suffix] != '-')) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
const auto patchValue = parseNumber(numericPatch);
|
||||||
|
if (!patchValue) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
version.patch = *patchValue;
|
||||||
|
version.hasPatch = true;
|
||||||
|
}
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string SpineVersion::line() const {
|
||||||
|
return std::to_string(major) + "." + std::to_string(minor);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string SpineVersion::toString() const {
|
||||||
|
auto value = line();
|
||||||
|
if (hasPatch) {
|
||||||
|
value += "." + std::to_string(patch);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SpineVersion::isSupported() const {
|
||||||
|
if (major == 3 && minor == 8) {
|
||||||
|
return !hasPatch || patch >= 20;
|
||||||
|
}
|
||||||
|
return major == 4 && minor >= 0 && minor <= 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace dts
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
namespace dts {
|
||||||
|
|
||||||
|
struct SpineVersion {
|
||||||
|
int major = 0;
|
||||||
|
int minor = 0;
|
||||||
|
int patch = 0;
|
||||||
|
bool hasPatch = false;
|
||||||
|
|
||||||
|
[[nodiscard]] static std::optional<SpineVersion> parse(std::string_view text);
|
||||||
|
[[nodiscard]] std::string line() const;
|
||||||
|
[[nodiscard]] std::string toString() const;
|
||||||
|
[[nodiscard]] bool isSupported() const;
|
||||||
|
|
||||||
|
friend bool operator==(const SpineVersion&, const SpineVersion&) = default;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace dts
|
||||||
|
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
#include "editor/SpineEditorLocator.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <system_error>
|
||||||
|
|
||||||
|
namespace dts {
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
void addUnique(std::vector<fs::path>& paths, const fs::path& candidate) {
|
||||||
|
std::error_code error;
|
||||||
|
if (!fs::is_regular_file(candidate, error)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const auto canonical = fs::weakly_canonical(candidate, error);
|
||||||
|
const auto value = error ? candidate.lexically_normal() : canonical;
|
||||||
|
if (std::find(paths.begin(), paths.end(), value) == paths.end()) {
|
||||||
|
paths.push_back(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::vector<fs::path> SpineEditorLocator::discover() const {
|
||||||
|
std::vector<fs::path> results;
|
||||||
|
|
||||||
|
#if defined(__APPLE__)
|
||||||
|
addUnique(results, "/Applications/Spine.app/Contents/MacOS/Spine");
|
||||||
|
if (const char* userHome = std::getenv("HOME")) {
|
||||||
|
addUnique(results, fs::path(userHome) / "Applications/Spine.app/Contents/MacOS/Spine");
|
||||||
|
}
|
||||||
|
#elif defined(_WIN32)
|
||||||
|
const auto addFromRoot = [&results](const char* variable) {
|
||||||
|
if (const char* root = std::getenv(variable)) {
|
||||||
|
addUnique(results, fs::path(root) / "Spine/Spine.com");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
addFromRoot("ProgramFiles");
|
||||||
|
addFromRoot("ProgramFiles(x86)");
|
||||||
|
#endif
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<fs::path> SpineEditorLocator::normalize(const fs::path& userPath) const {
|
||||||
|
std::error_code error;
|
||||||
|
auto candidate = userPath;
|
||||||
|
|
||||||
|
#if defined(__APPLE__)
|
||||||
|
if (candidate.extension() == ".app") {
|
||||||
|
candidate /= "Contents/MacOS/Spine";
|
||||||
|
} else if (fs::is_directory(candidate, error)) {
|
||||||
|
const auto appCandidate = candidate / "Spine.app/Contents/MacOS/Spine";
|
||||||
|
if (fs::is_regular_file(appCandidate, error)) {
|
||||||
|
candidate = appCandidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#elif defined(_WIN32)
|
||||||
|
if (fs::is_directory(candidate, error)) {
|
||||||
|
candidate /= "Spine.com";
|
||||||
|
} else if (candidate.extension() == ".exe") {
|
||||||
|
auto commandCandidate = candidate;
|
||||||
|
commandCandidate.replace_extension(".com");
|
||||||
|
if (fs::is_regular_file(commandCandidate, error)) {
|
||||||
|
candidate = commandCandidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
if (!fs::is_regular_file(candidate, error)) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
const auto canonical = fs::weakly_canonical(candidate, error);
|
||||||
|
return error ? std::optional(candidate.lexically_normal()) : std::optional(canonical);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace dts
|
||||||
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <filesystem>
|
||||||
|
#include <optional>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace dts {
|
||||||
|
|
||||||
|
class SpineEditorLocator {
|
||||||
|
public:
|
||||||
|
[[nodiscard]] std::vector<std::filesystem::path> discover() const;
|
||||||
|
[[nodiscard]] std::optional<std::filesystem::path> normalize(
|
||||||
|
const std::filesystem::path& userPath) const;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace dts
|
||||||
|
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
#include "editor/SpineOutputParser.hpp"
|
||||||
|
|
||||||
|
#include <regex>
|
||||||
|
|
||||||
|
namespace dts {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
std::optional<SpineVersion> firstVersion(
|
||||||
|
const std::string& text,
|
||||||
|
const std::regex& expression,
|
||||||
|
std::string* trailing = nullptr) {
|
||||||
|
std::smatch match;
|
||||||
|
if (!std::regex_search(text, match, expression)) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
if (trailing && match.size() > 2) {
|
||||||
|
*trailing = match[2].str();
|
||||||
|
}
|
||||||
|
return SpineVersion::parse(match[1].str());
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
EditorProbeResult SpineOutputParser::parse(
|
||||||
|
const std::filesystem::path& executable,
|
||||||
|
const ProcessResult& process) const {
|
||||||
|
EditorProbeResult result;
|
||||||
|
result.executable = executable;
|
||||||
|
result.timedOut = process.timedOut;
|
||||||
|
result.exitCode = process.exitCode;
|
||||||
|
result.output = process.output;
|
||||||
|
|
||||||
|
if (!process.started) {
|
||||||
|
result.status = EditorProbeStatus::LaunchFailed;
|
||||||
|
result.message = process.error.empty() ? "Spine process could not be started." : process.error;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
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]+))?)");
|
||||||
|
static const std::regex directEditor(
|
||||||
|
R"(^Spine\s+(\d+\.\d+(?:\.\d+)?)(?:\s+([^\r\n]+))?)",
|
||||||
|
std::regex_constants::multiline);
|
||||||
|
|
||||||
|
result.launcherVersion = firstVersion(process.output, launcher);
|
||||||
|
result.editorVersion = firstVersion(process.output, launching, &result.edition);
|
||||||
|
if (!result.editorVersion) {
|
||||||
|
result.editorVersion = firstVersion(process.output, directEditor, &result.edition);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.launcherVersion && !result.editorVersion) {
|
||||||
|
result.status = EditorProbeStatus::UnrecognizedOutput;
|
||||||
|
result.message = "Process started, but its output was not recognized as Spine.";
|
||||||
|
} else if (process.timedOut) {
|
||||||
|
result.status = EditorProbeStatus::ObservedButTimedOut;
|
||||||
|
result.message = "Spine was detected, but the version probe timed out.";
|
||||||
|
} else if (process.exitCode == 0) {
|
||||||
|
result.status = EditorProbeStatus::Available;
|
||||||
|
result.message = "Spine CLI probe succeeded.";
|
||||||
|
} else {
|
||||||
|
result.status = EditorProbeStatus::LaunchFailed;
|
||||||
|
result.message = "Spine was detected, but the process returned a failure code.";
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string toString(EditorProbeStatus status) {
|
||||||
|
switch (status) {
|
||||||
|
case EditorProbeStatus::Available: return "available";
|
||||||
|
case EditorProbeStatus::ObservedButTimedOut: return "observed-but-timed-out";
|
||||||
|
case EditorProbeStatus::PathMissing: return "path-missing";
|
||||||
|
case EditorProbeStatus::LaunchFailed: return "launch-failed";
|
||||||
|
case EditorProbeStatus::UnrecognizedOutput: return "unrecognized-output";
|
||||||
|
}
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace dts
|
||||||
|
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "domain/SpineVersion.hpp"
|
||||||
|
#include "platform/ProcessRunner.hpp"
|
||||||
|
|
||||||
|
#include <filesystem>
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace dts {
|
||||||
|
|
||||||
|
enum class EditorProbeStatus {
|
||||||
|
Available,
|
||||||
|
ObservedButTimedOut,
|
||||||
|
PathMissing,
|
||||||
|
LaunchFailed,
|
||||||
|
UnrecognizedOutput
|
||||||
|
};
|
||||||
|
|
||||||
|
struct EditorProbeResult {
|
||||||
|
std::filesystem::path executable;
|
||||||
|
EditorProbeStatus status = EditorProbeStatus::UnrecognizedOutput;
|
||||||
|
std::optional<SpineVersion> launcherVersion;
|
||||||
|
std::optional<SpineVersion> editorVersion;
|
||||||
|
std::string edition;
|
||||||
|
bool timedOut = false;
|
||||||
|
int exitCode = -1;
|
||||||
|
std::string output;
|
||||||
|
std::string message;
|
||||||
|
};
|
||||||
|
|
||||||
|
class SpineOutputParser {
|
||||||
|
public:
|
||||||
|
[[nodiscard]] EditorProbeResult parse(
|
||||||
|
const std::filesystem::path& executable,
|
||||||
|
const ProcessResult& process) const;
|
||||||
|
};
|
||||||
|
|
||||||
|
[[nodiscard]] std::string toString(EditorProbeStatus status);
|
||||||
|
|
||||||
|
} // namespace dts
|
||||||
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
#include "editor/SpineProbe.hpp"
|
||||||
|
|
||||||
|
#include <system_error>
|
||||||
|
|
||||||
|
namespace dts {
|
||||||
|
|
||||||
|
SpineProbe::SpineProbe(const IProcessRunner& processRunner)
|
||||||
|
: processRunner_(processRunner) {}
|
||||||
|
|
||||||
|
EditorProbeResult SpineProbe::inspect(
|
||||||
|
const std::filesystem::path& executable,
|
||||||
|
std::chrono::milliseconds timeout) const {
|
||||||
|
std::error_code error;
|
||||||
|
if (!std::filesystem::is_regular_file(executable, error)) {
|
||||||
|
EditorProbeResult result;
|
||||||
|
result.executable = executable;
|
||||||
|
result.status = EditorProbeStatus::PathMissing;
|
||||||
|
result.message = "Spine executable does not exist.";
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
ProcessRequest request;
|
||||||
|
request.program = executable;
|
||||||
|
request.arguments = {"--version"};
|
||||||
|
request.workingDirectory = executable.parent_path();
|
||||||
|
request.timeout = timeout;
|
||||||
|
return parser_.parse(executable, processRunner_.run(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace dts
|
||||||
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "editor/SpineOutputParser.hpp"
|
||||||
|
#include "platform/ProcessRunner.hpp"
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
#include <filesystem>
|
||||||
|
|
||||||
|
namespace dts {
|
||||||
|
|
||||||
|
class SpineProbe {
|
||||||
|
public:
|
||||||
|
explicit SpineProbe(const IProcessRunner& processRunner);
|
||||||
|
|
||||||
|
[[nodiscard]] EditorProbeResult inspect(
|
||||||
|
const std::filesystem::path& executable,
|
||||||
|
std::chrono::milliseconds timeout = std::chrono::seconds(10)) const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
const IProcessRunner& processRunner_;
|
||||||
|
SpineOutputParser parser_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace dts
|
||||||
|
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
#include "editor/SpineVersionAvailability.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <system_error>
|
||||||
|
|
||||||
|
namespace dts {
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
|
||||||
|
std::vector<fs::path> SpineVersionAvailability::defaultCacheRoots() const {
|
||||||
|
std::vector<fs::path> roots;
|
||||||
|
#if defined(__APPLE__)
|
||||||
|
if (const char* userHome = std::getenv("HOME"))
|
||||||
|
roots.emplace_back(fs::path(userHome) / "Library/Application Support/Spine/updates");
|
||||||
|
#elif defined(_WIN32)
|
||||||
|
if (const char* roaming = std::getenv("APPDATA"))
|
||||||
|
roots.emplace_back(fs::path(roaming) / "Spine/updates");
|
||||||
|
if (const char* local = std::getenv("LOCALAPPDATA"))
|
||||||
|
roots.emplace_back(fs::path(local) / "Spine/updates");
|
||||||
|
#endif
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<SpineVersion> SpineVersionAvailability::discover(
|
||||||
|
const std::vector<fs::path>& cacheRoots) const {
|
||||||
|
std::vector<SpineVersion> versions;
|
||||||
|
const auto roots = cacheRoots.empty() ? defaultCacheRoots() : cacheRoots;
|
||||||
|
for (const auto& root : roots) {
|
||||||
|
std::error_code error;
|
||||||
|
fs::directory_iterator iterator(root, error);
|
||||||
|
for (const auto& entry : iterator) {
|
||||||
|
if (!entry.is_regular_file(error) || entry.file_size(error) == 0) continue;
|
||||||
|
const auto parsed = SpineVersion::parse(entry.path().filename().string());
|
||||||
|
if (parsed && std::find(versions.begin(), versions.end(), *parsed) == versions.end())
|
||||||
|
versions.push_back(*parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::sort(versions.begin(), versions.end(), [](const auto& left, const auto& right) {
|
||||||
|
if (left.major != right.major) return left.major < right.major;
|
||||||
|
if (left.minor != right.minor) return left.minor < right.minor;
|
||||||
|
return left.patch < right.patch;
|
||||||
|
});
|
||||||
|
return versions;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<SpineVersion> SpineVersionAvailability::newestForLine(
|
||||||
|
std::string_view versionLine, const std::vector<fs::path>& cacheRoots) const {
|
||||||
|
std::optional<SpineVersion> result;
|
||||||
|
for (const auto& version : discover(cacheRoots))
|
||||||
|
if (version.line() == versionLine) result = version;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SpineVersionAvailability::isCached(
|
||||||
|
const SpineVersion& version, const std::vector<fs::path>& cacheRoots) const {
|
||||||
|
const auto versions = discover(cacheRoots);
|
||||||
|
return std::find(versions.begin(), versions.end(), version) != versions.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace dts
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "domain/SpineVersion.hpp"
|
||||||
|
|
||||||
|
#include <filesystem>
|
||||||
|
#include <optional>
|
||||||
|
#include <string_view>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace dts {
|
||||||
|
|
||||||
|
class SpineVersionAvailability {
|
||||||
|
public:
|
||||||
|
[[nodiscard]] std::vector<std::filesystem::path> defaultCacheRoots() const;
|
||||||
|
[[nodiscard]] std::vector<SpineVersion> discover(
|
||||||
|
const std::vector<std::filesystem::path>& cacheRoots = {}) const;
|
||||||
|
[[nodiscard]] std::optional<SpineVersion> newestForLine(
|
||||||
|
std::string_view versionLine,
|
||||||
|
const std::vector<std::filesystem::path>& cacheRoots = {}) const;
|
||||||
|
[[nodiscard]] bool isCached(
|
||||||
|
const SpineVersion& version,
|
||||||
|
const std::vector<std::filesystem::path>& cacheRoots = {}) const;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace dts
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace dts {
|
||||||
|
|
||||||
|
struct ProcessRequest {
|
||||||
|
std::filesystem::path program;
|
||||||
|
std::vector<std::string> arguments;
|
||||||
|
std::filesystem::path workingDirectory;
|
||||||
|
std::chrono::milliseconds timeout{10000};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct ProcessResult {
|
||||||
|
bool started = false;
|
||||||
|
bool timedOut = false;
|
||||||
|
int exitCode = -1;
|
||||||
|
std::string output;
|
||||||
|
std::string error;
|
||||||
|
};
|
||||||
|
|
||||||
|
class IProcessRunner {
|
||||||
|
public:
|
||||||
|
virtual ~IProcessRunner() = default;
|
||||||
|
[[nodiscard]] virtual ProcessResult run(const ProcessRequest& request) const = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
[[nodiscard]] std::unique_ptr<IProcessRunner> createDefaultProcessRunner();
|
||||||
|
|
||||||
|
} // namespace dts
|
||||||
|
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
#include "platform/ProcessRunner.hpp"
|
||||||
|
|
||||||
|
#include <cerrno>
|
||||||
|
#include <chrono>
|
||||||
|
#include <csignal>
|
||||||
|
#include <cstring>
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <memory>
|
||||||
|
#include <poll.h>
|
||||||
|
#include <sys/types.h>
|
||||||
|
#include <sys/wait.h>
|
||||||
|
#include <thread>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
namespace dts {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
void appendAvailableOutput(int descriptor, std::string& output) {
|
||||||
|
char buffer[4096];
|
||||||
|
while (true) {
|
||||||
|
const auto count = ::read(descriptor, buffer, sizeof(buffer));
|
||||||
|
if (count > 0) {
|
||||||
|
output.append(buffer, static_cast<std::size_t>(count));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (count < 0 && errno == EINTR) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PosixProcessRunner final : public IProcessRunner {
|
||||||
|
public:
|
||||||
|
ProcessResult run(const ProcessRequest& request) const override {
|
||||||
|
ProcessResult result;
|
||||||
|
int outputPipe[2];
|
||||||
|
if (::pipe(outputPipe) != 0) {
|
||||||
|
result.error = std::strerror(errno);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pid_t child = ::fork();
|
||||||
|
if (child < 0) {
|
||||||
|
result.error = std::strerror(errno);
|
||||||
|
::close(outputPipe[0]);
|
||||||
|
::close(outputPipe[1]);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (child == 0) {
|
||||||
|
::setpgid(0, 0);
|
||||||
|
::close(outputPipe[0]);
|
||||||
|
::dup2(outputPipe[1], STDOUT_FILENO);
|
||||||
|
::dup2(outputPipe[1], STDERR_FILENO);
|
||||||
|
::close(outputPipe[1]);
|
||||||
|
|
||||||
|
if (!request.workingDirectory.empty()) {
|
||||||
|
::chdir(request.workingDirectory.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::string> values;
|
||||||
|
values.reserve(request.arguments.size() + 1);
|
||||||
|
values.push_back(request.program.string());
|
||||||
|
values.insert(values.end(), request.arguments.begin(), request.arguments.end());
|
||||||
|
|
||||||
|
std::vector<char*> arguments;
|
||||||
|
arguments.reserve(values.size() + 1);
|
||||||
|
for (auto& value : values) {
|
||||||
|
arguments.push_back(value.data());
|
||||||
|
}
|
||||||
|
arguments.push_back(nullptr);
|
||||||
|
::execv(request.program.c_str(), arguments.data());
|
||||||
|
::_exit(127);
|
||||||
|
}
|
||||||
|
|
||||||
|
result.started = true;
|
||||||
|
::setpgid(child, child);
|
||||||
|
::close(outputPipe[1]);
|
||||||
|
const auto flags = ::fcntl(outputPipe[0], F_GETFL, 0);
|
||||||
|
::fcntl(outputPipe[0], F_SETFL, flags | O_NONBLOCK);
|
||||||
|
|
||||||
|
const auto deadline = std::chrono::steady_clock::now() + request.timeout;
|
||||||
|
int status = 0;
|
||||||
|
bool finished = false;
|
||||||
|
while (!finished) {
|
||||||
|
appendAvailableOutput(outputPipe[0], result.output);
|
||||||
|
const auto waitResult = ::waitpid(child, &status, WNOHANG);
|
||||||
|
if (waitResult == child) {
|
||||||
|
finished = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (waitResult < 0) {
|
||||||
|
result.error = std::strerror(errno);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (std::chrono::steady_clock::now() >= deadline) {
|
||||||
|
result.timedOut = true;
|
||||||
|
::kill(-child, SIGTERM);
|
||||||
|
for (int attempt = 0; attempt < 25; ++attempt) {
|
||||||
|
if (::waitpid(child, &status, WNOHANG) == child) {
|
||||||
|
finished = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
||||||
|
}
|
||||||
|
if (!finished) {
|
||||||
|
::kill(-child, SIGKILL);
|
||||||
|
::waitpid(child, &status, 0);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
pollfd descriptor{outputPipe[0], POLLIN, 0};
|
||||||
|
::poll(&descriptor, 1, 25);
|
||||||
|
}
|
||||||
|
|
||||||
|
appendAvailableOutput(outputPipe[0], result.output);
|
||||||
|
::close(outputPipe[0]);
|
||||||
|
if (WIFEXITED(status)) {
|
||||||
|
result.exitCode = WEXITSTATUS(status);
|
||||||
|
} else if (WIFSIGNALED(status)) {
|
||||||
|
result.exitCode = 128 + WTERMSIG(status);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::unique_ptr<IProcessRunner> createDefaultProcessRunner() {
|
||||||
|
return std::make_unique<PosixProcessRunner>();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace dts
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
#include "platform/ProcessRunner.hpp"
|
||||||
|
|
||||||
|
#define NOMINMAX
|
||||||
|
#include <windows.h>
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
#include <sstream>
|
||||||
|
|
||||||
|
namespace dts {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
std::wstring widen(const std::string& value) {
|
||||||
|
if (value.empty()) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const int size = MultiByteToWideChar(CP_UTF8, 0, value.data(),
|
||||||
|
static_cast<int>(value.size()), nullptr, 0);
|
||||||
|
std::wstring output(static_cast<std::size_t>(size), L'\0');
|
||||||
|
MultiByteToWideChar(CP_UTF8, 0, value.data(), static_cast<int>(value.size()),
|
||||||
|
output.data(), size);
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::wstring quote(const std::wstring& value) {
|
||||||
|
if (value.find_first_of(L" \t\"") == std::wstring::npos) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
std::wstring output = L"\"";
|
||||||
|
std::size_t slashes = 0;
|
||||||
|
for (const auto character : value) {
|
||||||
|
if (character == L'\\') {
|
||||||
|
++slashes;
|
||||||
|
} else if (character == L'\"') {
|
||||||
|
output.append(slashes * 2 + 1, L'\\');
|
||||||
|
output += character;
|
||||||
|
slashes = 0;
|
||||||
|
} else {
|
||||||
|
output.append(slashes, L'\\');
|
||||||
|
output += character;
|
||||||
|
slashes = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
output.append(slashes * 2, L'\\');
|
||||||
|
return output + L"\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
std::wstring commandLine(const ProcessRequest& request) {
|
||||||
|
std::wstring value = quote(request.program.wstring());
|
||||||
|
for (const auto& argument : request.arguments) {
|
||||||
|
value += L" " + quote(widen(argument));
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
class WindowsProcessRunner final : public IProcessRunner {
|
||||||
|
public:
|
||||||
|
ProcessResult run(const ProcessRequest& request) const override {
|
||||||
|
ProcessResult result;
|
||||||
|
SECURITY_ATTRIBUTES attributes{sizeof(SECURITY_ATTRIBUTES), nullptr, TRUE};
|
||||||
|
HANDLE readPipe = nullptr;
|
||||||
|
HANDLE writePipe = nullptr;
|
||||||
|
if (!CreatePipe(&readPipe, &writePipe, &attributes, 0)) {
|
||||||
|
result.error = "CreatePipe failed.";
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
SetHandleInformation(readPipe, HANDLE_FLAG_INHERIT, 0);
|
||||||
|
|
||||||
|
STARTUPINFOW startup{};
|
||||||
|
startup.cb = sizeof(startup);
|
||||||
|
startup.dwFlags = STARTF_USESTDHANDLES;
|
||||||
|
startup.hStdOutput = writePipe;
|
||||||
|
startup.hStdError = writePipe;
|
||||||
|
startup.hStdInput = GetStdHandle(STD_INPUT_HANDLE);
|
||||||
|
PROCESS_INFORMATION process{};
|
||||||
|
auto command = commandLine(request);
|
||||||
|
const auto directory = request.workingDirectory.wstring();
|
||||||
|
|
||||||
|
const BOOL created = CreateProcessW(request.program.wstring().c_str(), command.data(),
|
||||||
|
nullptr, nullptr, TRUE, CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP, nullptr,
|
||||||
|
directory.empty() ? nullptr : directory.c_str(), &startup, &process);
|
||||||
|
CloseHandle(writePipe);
|
||||||
|
if (!created) {
|
||||||
|
CloseHandle(readPipe);
|
||||||
|
result.error = "CreateProcessW failed: " + std::to_string(GetLastError());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.started = true;
|
||||||
|
HANDLE job = CreateJobObjectW(nullptr, nullptr);
|
||||||
|
if (job) {
|
||||||
|
JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits{};
|
||||||
|
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
||||||
|
SetInformationJobObject(job, JobObjectExtendedLimitInformation, &limits, sizeof(limits));
|
||||||
|
AssignProcessToJobObject(job, process.hProcess);
|
||||||
|
}
|
||||||
|
const DWORD waitTime = static_cast<DWORD>(request.timeout.count());
|
||||||
|
if (WaitForSingleObject(process.hProcess, waitTime) == WAIT_TIMEOUT) {
|
||||||
|
result.timedOut = true;
|
||||||
|
if (job) {
|
||||||
|
TerminateJobObject(job, 1);
|
||||||
|
} else {
|
||||||
|
TerminateProcess(process.hProcess, 1);
|
||||||
|
}
|
||||||
|
WaitForSingleObject(process.hProcess, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
char buffer[4096];
|
||||||
|
DWORD count = 0;
|
||||||
|
while (ReadFile(readPipe, buffer, sizeof(buffer), &count, nullptr) && count > 0) {
|
||||||
|
result.output.append(buffer, count);
|
||||||
|
}
|
||||||
|
DWORD exitCode = 1;
|
||||||
|
GetExitCodeProcess(process.hProcess, &exitCode);
|
||||||
|
result.exitCode = static_cast<int>(exitCode);
|
||||||
|
|
||||||
|
CloseHandle(readPipe);
|
||||||
|
if (job) {
|
||||||
|
CloseHandle(job);
|
||||||
|
}
|
||||||
|
CloseHandle(process.hThread);
|
||||||
|
CloseHandle(process.hProcess);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::unique_ptr<IProcessRunner> createDefaultProcessRunner() {
|
||||||
|
return std::make_unique<WindowsProcessRunner>();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace dts
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
#include "preview/RuntimePlugin.hpp"
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
#if defined(_WIN32)
|
||||||
|
#define NOMINMAX
|
||||||
|
#include <windows.h>
|
||||||
|
#else
|
||||||
|
#include <dlfcn.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
namespace dts {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
void* openLibrary(const std::filesystem::path& path, std::string& error) {
|
||||||
|
#if defined(_WIN32)
|
||||||
|
auto* handle = LoadLibraryW(path.wstring().c_str());
|
||||||
|
if (!handle) {
|
||||||
|
error = "LoadLibraryW failed: " + std::to_string(GetLastError());
|
||||||
|
}
|
||||||
|
return handle;
|
||||||
|
#else
|
||||||
|
auto* handle = dlopen(path.c_str(), RTLD_NOW | RTLD_LOCAL);
|
||||||
|
if (!handle) {
|
||||||
|
error = dlerror();
|
||||||
|
}
|
||||||
|
return handle;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void closeLibrary(void* handle) {
|
||||||
|
#if defined(_WIN32)
|
||||||
|
FreeLibrary(static_cast<HMODULE>(handle));
|
||||||
|
#else
|
||||||
|
dlclose(handle);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void* findSymbol(void* handle, const char* name) {
|
||||||
|
#if defined(_WIN32)
|
||||||
|
return reinterpret_cast<void*>(GetProcAddress(static_cast<HMODULE>(handle), name));
|
||||||
|
#else
|
||||||
|
return dlsym(handle, name);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
template <typename Function>
|
||||||
|
Function functionPointer(void* symbol) {
|
||||||
|
Function function = nullptr;
|
||||||
|
static_assert(sizeof(function) == sizeof(symbol));
|
||||||
|
std::memcpy(&function, &symbol, sizeof(function));
|
||||||
|
return function;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string utf8Path(const std::filesystem::path& path) {
|
||||||
|
const auto value = path.u8string();
|
||||||
|
return {reinterpret_cast<const char*>(value.data()), value.size()};
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
RuntimePlugin::~RuntimePlugin() {
|
||||||
|
unload();
|
||||||
|
}
|
||||||
|
|
||||||
|
RuntimePlugin::RuntimePlugin(RuntimePlugin&& other) noexcept {
|
||||||
|
*this = std::move(other);
|
||||||
|
}
|
||||||
|
|
||||||
|
RuntimePlugin& RuntimePlugin::operator=(RuntimePlugin&& other) noexcept {
|
||||||
|
if (this != &other) {
|
||||||
|
unload();
|
||||||
|
handle_ = std::exchange(other.handle_, nullptr);
|
||||||
|
getApiVersion_ = std::exchange(other.getApiVersion_, nullptr);
|
||||||
|
getInfo_ = std::exchange(other.getInfo_, nullptr);
|
||||||
|
create_ = std::exchange(other.create_, nullptr);
|
||||||
|
destroy_ = std::exchange(other.destroy_, nullptr);
|
||||||
|
load_ = std::exchange(other.load_, nullptr);
|
||||||
|
getSkeletonInfo_ = std::exchange(other.getSkeletonInfo_, nullptr);
|
||||||
|
getAnimationName_ = std::exchange(other.getAnimationName_, nullptr);
|
||||||
|
getAnimationDuration_ = std::exchange(other.getAnimationDuration_, nullptr);
|
||||||
|
getSkinName_ = std::exchange(other.getSkinName_, nullptr);
|
||||||
|
getLastError_ = std::exchange(other.getLastError_, nullptr);
|
||||||
|
setAnimation_ = std::exchange(other.setAnimation_, nullptr);
|
||||||
|
update_ = std::exchange(other.update_, nullptr);
|
||||||
|
getDrawCommandCount_ = std::exchange(other.getDrawCommandCount_, nullptr);
|
||||||
|
getDrawCommand_ = std::exchange(other.getDrawCommand_, nullptr);
|
||||||
|
context_ = std::exchange(other.context_, nullptr);
|
||||||
|
}
|
||||||
|
return *this;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool RuntimePlugin::load(const std::filesystem::path& path, std::string& error) {
|
||||||
|
unload();
|
||||||
|
handle_ = openLibrary(path, error);
|
||||||
|
if (!handle_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
getApiVersion_ = functionPointer<GetApiVersion>(findSymbol(handle_, "dtsRuntimeGetApiVersion"));
|
||||||
|
getInfo_ = functionPointer<GetInfo>(findSymbol(handle_, "dtsRuntimeGetInfo"));
|
||||||
|
if (!getApiVersion_ || !getInfo_) {
|
||||||
|
error = "Runtime plugin does not expose the required DateToSpine ABI.";
|
||||||
|
unload();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (getApiVersion_() != DTS_RUNTIME_API_VERSION) {
|
||||||
|
error = "Runtime plugin API version is incompatible.";
|
||||||
|
unload();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
create_ = functionPointer<Create>(findSymbol(handle_, "dtsRuntimeCreate"));
|
||||||
|
destroy_ = functionPointer<Destroy>(findSymbol(handle_, "dtsRuntimeDestroy"));
|
||||||
|
load_ = functionPointer<Load>(findSymbol(handle_, "dtsRuntimeLoad"));
|
||||||
|
getSkeletonInfo_ = functionPointer<GetSkeletonInfo>(
|
||||||
|
findSymbol(handle_, "dtsRuntimeGetSkeletonInfo"));
|
||||||
|
getAnimationName_ = functionPointer<GetName>(
|
||||||
|
findSymbol(handle_, "dtsRuntimeGetAnimationName"));
|
||||||
|
getAnimationDuration_ = functionPointer<GetDuration>(
|
||||||
|
findSymbol(handle_, "dtsRuntimeGetAnimationDuration"));
|
||||||
|
getSkinName_ = functionPointer<GetName>(findSymbol(handle_, "dtsRuntimeGetSkinName"));
|
||||||
|
getLastError_ = functionPointer<GetLastError>(findSymbol(handle_, "dtsRuntimeGetLastError"));
|
||||||
|
setAnimation_ = functionPointer<SetAnimation>(findSymbol(handle_, "dtsRuntimeSetAnimation"));
|
||||||
|
update_ = functionPointer<Update>(findSymbol(handle_, "dtsRuntimeUpdate"));
|
||||||
|
getDrawCommandCount_ = functionPointer<GetDrawCommandCount>(
|
||||||
|
findSymbol(handle_, "dtsRuntimeGetDrawCommandCount"));
|
||||||
|
getDrawCommand_ = functionPointer<GetDrawCommand>(
|
||||||
|
findSymbol(handle_, "dtsRuntimeGetDrawCommand"));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void RuntimePlugin::unload() {
|
||||||
|
if (context_ && destroy_) {
|
||||||
|
destroy_(context_);
|
||||||
|
}
|
||||||
|
context_ = nullptr;
|
||||||
|
getApiVersion_ = nullptr;
|
||||||
|
getInfo_ = nullptr;
|
||||||
|
create_ = nullptr;
|
||||||
|
destroy_ = nullptr;
|
||||||
|
load_ = nullptr;
|
||||||
|
getSkeletonInfo_ = nullptr;
|
||||||
|
getAnimationName_ = nullptr;
|
||||||
|
getAnimationDuration_ = nullptr;
|
||||||
|
getSkinName_ = nullptr;
|
||||||
|
getLastError_ = nullptr;
|
||||||
|
setAnimation_ = nullptr;
|
||||||
|
update_ = nullptr;
|
||||||
|
getDrawCommandCount_ = nullptr;
|
||||||
|
getDrawCommand_ = nullptr;
|
||||||
|
if (handle_) {
|
||||||
|
closeLibrary(handle_);
|
||||||
|
handle_ = nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool RuntimePlugin::isLoaded() const {
|
||||||
|
return handle_ != nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<RuntimePluginMetadata> RuntimePlugin::metadata(std::string& error) const {
|
||||||
|
if (!getInfo_) {
|
||||||
|
error = "Runtime plugin is not loaded.";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
DtsRuntimeInfo info{};
|
||||||
|
info.structSize = sizeof(info);
|
||||||
|
if (getInfo_(&info) != 0 || !info.spineVersionLine || !info.adapterBuild) {
|
||||||
|
error = "Runtime plugin returned invalid metadata.";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
return RuntimePluginMetadata{
|
||||||
|
info.apiVersion,
|
||||||
|
info.spineVersionLine,
|
||||||
|
info.adapterBuild,
|
||||||
|
info.capabilities
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<SkeletonMetadata> RuntimePlugin::loadSkeleton(
|
||||||
|
const std::filesystem::path& skeletonPath,
|
||||||
|
const std::filesystem::path& atlasPath,
|
||||||
|
float scale,
|
||||||
|
std::string& error) {
|
||||||
|
if (!create_ || !destroy_ || !load_ || !getSkeletonInfo_ || !getAnimationName_
|
||||||
|
|| !getAnimationDuration_ || !getSkinName_ || !getLastError_) {
|
||||||
|
error = "Runtime plugin does not provide skeleton metadata capability.";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
if (context_) {
|
||||||
|
destroy_(context_);
|
||||||
|
}
|
||||||
|
context_ = create_();
|
||||||
|
if (!context_) {
|
||||||
|
error = "Runtime plugin could not create a context.";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto skeleton = utf8Path(skeletonPath);
|
||||||
|
const auto atlas = utf8Path(atlasPath);
|
||||||
|
DtsLoadRequest request{sizeof(DtsLoadRequest), skeleton.c_str(), atlas.c_str(), scale};
|
||||||
|
if (load_(context_, &request) != 0) {
|
||||||
|
const auto* detail = getLastError_(context_);
|
||||||
|
error = detail ? detail : "Runtime plugin failed to load skeleton data.";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
DtsSkeletonInfo info{};
|
||||||
|
info.structSize = sizeof(info);
|
||||||
|
if (getSkeletonInfo_(context_, &info) != 0) {
|
||||||
|
error = "Runtime plugin failed to return skeleton metadata.";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
SkeletonMetadata result;
|
||||||
|
result.boneCount = info.boneCount;
|
||||||
|
result.slotCount = info.slotCount;
|
||||||
|
result.eventCount = info.eventCount;
|
||||||
|
result.constraintCount = info.constraintCount;
|
||||||
|
result.animations.reserve(info.animationCount);
|
||||||
|
for (std::uint32_t index = 0; index < info.animationCount; ++index) {
|
||||||
|
const auto* name = getAnimationName_(context_, index);
|
||||||
|
result.animations.push_back({name ? name : "", getAnimationDuration_(context_, index)});
|
||||||
|
}
|
||||||
|
result.skins.reserve(info.skinCount);
|
||||||
|
for (std::uint32_t index = 0; index < info.skinCount; ++index) {
|
||||||
|
const auto* name = getSkinName_(context_, index);
|
||||||
|
result.skins.emplace_back(name ? name : "");
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool RuntimePlugin::setAnimation(const std::string& name, bool loop, std::string& error) {
|
||||||
|
if (!context_ || !setAnimation_) {
|
||||||
|
error = "Runtime plugin does not provide animation playback.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (setAnimation_(context_, name.c_str(), loop ? 1 : 0) != 0) {
|
||||||
|
const auto* detail = getLastError_ ? getLastError_(context_) : nullptr;
|
||||||
|
error = detail ? detail : "Animation could not be selected.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<std::vector<PreviewDrawCommand>> RuntimePlugin::updatePreview(
|
||||||
|
float deltaSeconds, std::string& error) {
|
||||||
|
if (!context_ || !update_ || !getDrawCommandCount_ || !getDrawCommand_) {
|
||||||
|
error = "Runtime plugin does not provide preview drawing.";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
if (update_(context_, deltaSeconds) != 0) {
|
||||||
|
const auto* detail = getLastError_ ? getLastError_(context_) : nullptr;
|
||||||
|
error = detail ? detail : "Preview frame could not be sampled.";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
std::vector<PreviewDrawCommand> result;
|
||||||
|
const auto count = getDrawCommandCount_(context_);
|
||||||
|
result.reserve(count);
|
||||||
|
for (std::uint32_t index = 0; index < count; ++index) {
|
||||||
|
DtsDrawCommand source{};
|
||||||
|
source.structSize = sizeof(source);
|
||||||
|
if (getDrawCommand_(context_, index, &source) != 0 || !source.texturePathUtf8) {
|
||||||
|
error = "Runtime plugin returned an invalid draw command.";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
PreviewDrawCommand command;
|
||||||
|
command.texturePath = std::filesystem::path(
|
||||||
|
std::u8string(reinterpret_cast<const char8_t*>(source.texturePathUtf8)));
|
||||||
|
command.blendMode = source.blendMode;
|
||||||
|
command.indices.assign(source.indices, source.indices + source.indexCount);
|
||||||
|
command.vertices.reserve(source.vertexCount);
|
||||||
|
for (std::uint32_t vertex = 0; vertex < source.vertexCount; ++vertex) {
|
||||||
|
const auto& value = source.vertices[vertex];
|
||||||
|
command.vertices.push_back({value.x, value.y, value.u, value.v, value.color});
|
||||||
|
}
|
||||||
|
result.push_back(std::move(command));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace dts
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "RuntimeApi.h"
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace dts {
|
||||||
|
|
||||||
|
struct RuntimePluginMetadata {
|
||||||
|
std::uint32_t apiVersion = 0;
|
||||||
|
std::string spineVersionLine;
|
||||||
|
std::string adapterBuild;
|
||||||
|
std::uint32_t capabilities = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct AnimationMetadata {
|
||||||
|
std::string name;
|
||||||
|
float duration = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct SkeletonMetadata {
|
||||||
|
std::uint32_t boneCount = 0;
|
||||||
|
std::uint32_t slotCount = 0;
|
||||||
|
std::uint32_t eventCount = 0;
|
||||||
|
std::uint32_t constraintCount = 0;
|
||||||
|
std::vector<std::string> skins;
|
||||||
|
std::vector<AnimationMetadata> animations;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct PreviewVertex {
|
||||||
|
float x = 0;
|
||||||
|
float y = 0;
|
||||||
|
float u = 0;
|
||||||
|
float v = 0;
|
||||||
|
std::uint32_t color = 0xffffffff;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct PreviewDrawCommand {
|
||||||
|
std::vector<PreviewVertex> vertices;
|
||||||
|
std::vector<std::uint16_t> indices;
|
||||||
|
std::filesystem::path texturePath;
|
||||||
|
std::uint32_t blendMode = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
class RuntimePlugin {
|
||||||
|
public:
|
||||||
|
RuntimePlugin() = default;
|
||||||
|
~RuntimePlugin();
|
||||||
|
|
||||||
|
RuntimePlugin(const RuntimePlugin&) = delete;
|
||||||
|
RuntimePlugin& operator=(const RuntimePlugin&) = delete;
|
||||||
|
RuntimePlugin(RuntimePlugin&& other) noexcept;
|
||||||
|
RuntimePlugin& operator=(RuntimePlugin&& other) noexcept;
|
||||||
|
|
||||||
|
[[nodiscard]] bool load(const std::filesystem::path& path, std::string& error);
|
||||||
|
void unload();
|
||||||
|
[[nodiscard]] bool isLoaded() const;
|
||||||
|
[[nodiscard]] std::optional<RuntimePluginMetadata> metadata(std::string& error) const;
|
||||||
|
[[nodiscard]] std::optional<SkeletonMetadata> loadSkeleton(
|
||||||
|
const std::filesystem::path& skeletonPath,
|
||||||
|
const std::filesystem::path& atlasPath,
|
||||||
|
float scale,
|
||||||
|
std::string& error);
|
||||||
|
[[nodiscard]] bool setAnimation(const std::string& name, bool loop, std::string& error);
|
||||||
|
[[nodiscard]] std::optional<std::vector<PreviewDrawCommand>> updatePreview(
|
||||||
|
float deltaSeconds, std::string& error);
|
||||||
|
|
||||||
|
private:
|
||||||
|
using GetApiVersion = std::uint32_t (*)();
|
||||||
|
using GetInfo = std::int32_t (*)(DtsRuntimeInfo*);
|
||||||
|
using Create = DtsRuntimeHandle (*)();
|
||||||
|
using Destroy = void (*)(DtsRuntimeHandle);
|
||||||
|
using Load = std::int32_t (*)(DtsRuntimeHandle, const DtsLoadRequest*);
|
||||||
|
using GetSkeletonInfo = std::int32_t (*)(DtsRuntimeHandle, DtsSkeletonInfo*);
|
||||||
|
using GetName = const char* (*)(DtsRuntimeHandle, std::uint32_t);
|
||||||
|
using GetDuration = float (*)(DtsRuntimeHandle, std::uint32_t);
|
||||||
|
using GetLastError = const char* (*)(DtsRuntimeHandle);
|
||||||
|
using SetAnimation = std::int32_t (*)(DtsRuntimeHandle, const char*, std::int32_t);
|
||||||
|
using Update = std::int32_t (*)(DtsRuntimeHandle, float);
|
||||||
|
using GetDrawCommandCount = std::uint32_t (*)(DtsRuntimeHandle);
|
||||||
|
using GetDrawCommand = std::int32_t (*)(DtsRuntimeHandle, std::uint32_t, DtsDrawCommand*);
|
||||||
|
|
||||||
|
void* handle_ = nullptr;
|
||||||
|
GetApiVersion getApiVersion_ = nullptr;
|
||||||
|
GetInfo getInfo_ = nullptr;
|
||||||
|
Create create_ = nullptr;
|
||||||
|
Destroy destroy_ = nullptr;
|
||||||
|
Load load_ = nullptr;
|
||||||
|
GetSkeletonInfo getSkeletonInfo_ = nullptr;
|
||||||
|
GetName getAnimationName_ = nullptr;
|
||||||
|
GetDuration getAnimationDuration_ = nullptr;
|
||||||
|
GetName getSkinName_ = nullptr;
|
||||||
|
GetLastError getLastError_ = nullptr;
|
||||||
|
SetAnimation setAnimation_ = nullptr;
|
||||||
|
Update update_ = nullptr;
|
||||||
|
GetDrawCommandCount getDrawCommandCount_ = nullptr;
|
||||||
|
GetDrawCommand getDrawCommand_ = nullptr;
|
||||||
|
DtsRuntimeHandle context_ = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace dts
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
#include "scan/SpineDataVersionDetector.hpp"
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <fstream>
|
||||||
|
#include <regex>
|
||||||
|
|
||||||
|
namespace dts {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr std::size_t maxJsonProbeBytes = 1024 * 1024;
|
||||||
|
constexpr std::size_t maxBinaryStringBytes = 4096;
|
||||||
|
|
||||||
|
std::optional<std::uint32_t> readVarint(std::istream& input) {
|
||||||
|
std::uint32_t value = 0;
|
||||||
|
for (int shift = 0; shift < 35; shift += 7) {
|
||||||
|
const int byte = input.get();
|
||||||
|
if (byte == std::char_traits<char>::eof()) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
value |= static_cast<std::uint32_t>(byte & 0x7f) << shift;
|
||||||
|
if ((byte & 0x80) == 0) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<std::string> readBinaryString(std::istream& input) {
|
||||||
|
const auto encodedLength = readVarint(input);
|
||||||
|
if (!encodedLength || *encodedLength == 0) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
const std::size_t length = *encodedLength - 1;
|
||||||
|
if (length > maxBinaryStringBytes) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
std::string value(length, '\0');
|
||||||
|
if (length > 0 && !input.read(value.data(), static_cast<std::streamsize>(length))) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
VersionDetectionResult finish(SkeletonDataFormat format, const SpineVersion& version) {
|
||||||
|
VersionDetectionResult result;
|
||||||
|
result.format = format;
|
||||||
|
result.version = version;
|
||||||
|
result.status = version.isSupported()
|
||||||
|
? VersionDetectionStatus::Detected
|
||||||
|
: VersionDetectionStatus::UnsupportedVersion;
|
||||||
|
result.message = version.isSupported()
|
||||||
|
? "Spine data version detected."
|
||||||
|
: "Spine data version is outside the supported range.";
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
VersionDetectionResult inspectJson(std::ifstream& input) {
|
||||||
|
std::string text(maxJsonProbeBytes, '\0');
|
||||||
|
input.read(text.data(), static_cast<std::streamsize>(text.size()));
|
||||||
|
text.resize(static_cast<std::size_t>(input.gcount()));
|
||||||
|
|
||||||
|
static const std::regex bones(R"("bones"\s*:)");
|
||||||
|
static const std::regex version(
|
||||||
|
R"regex("spine"\s*:\s*"(\d+\.\d+(?:\.\d+)?(?:-[^"]+)?)")regex");
|
||||||
|
std::smatch match;
|
||||||
|
if (!std::regex_search(text, bones) || !std::regex_search(text, match, version)) {
|
||||||
|
return {SkeletonDataFormat::Json, VersionDetectionStatus::InvalidData, std::nullopt,
|
||||||
|
"JSON does not contain the minimum Spine skeleton schema."};
|
||||||
|
}
|
||||||
|
const auto parsed = SpineVersion::parse(match[1].str());
|
||||||
|
if (!parsed) {
|
||||||
|
return {SkeletonDataFormat::Json, VersionDetectionStatus::InvalidData, std::nullopt,
|
||||||
|
"Spine JSON version is invalid."};
|
||||||
|
}
|
||||||
|
return finish(SkeletonDataFormat::Json, *parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
VersionDetectionResult inspectBinary(std::ifstream& input) {
|
||||||
|
const auto hash = readBinaryString(input);
|
||||||
|
const auto versionText = readBinaryString(input);
|
||||||
|
if (hash && versionText) {
|
||||||
|
if (const auto version = SpineVersion::parse(*versionText))
|
||||||
|
return finish(SkeletonDataFormat::Binary, *version);
|
||||||
|
}
|
||||||
|
|
||||||
|
input.clear();
|
||||||
|
input.seekg(0);
|
||||||
|
std::string header(256, '\0');
|
||||||
|
input.read(header.data(), static_cast<std::streamsize>(header.size()));
|
||||||
|
header.resize(static_cast<std::size_t>(input.gcount()));
|
||||||
|
static const std::regex embeddedVersion(
|
||||||
|
R"regex((3\.8(?:\.\d+)?(?:-[A-Za-z0-9.-]+)?|4\.[0-3](?:\.\d+)?(?:-[A-Za-z0-9.-]+)?))regex");
|
||||||
|
std::smatch match;
|
||||||
|
if (std::regex_search(header, match, embeddedVersion)) {
|
||||||
|
if (const auto version = SpineVersion::parse(match[1].str()))
|
||||||
|
return finish(SkeletonDataFormat::Binary, *version);
|
||||||
|
}
|
||||||
|
return {SkeletonDataFormat::Binary, VersionDetectionStatus::InvalidData, std::nullopt,
|
||||||
|
"SKEL header does not contain a valid Spine version."};
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
VersionDetectionResult SpineDataVersionDetector::inspect(const std::filesystem::path& path) const {
|
||||||
|
std::ifstream input(path, std::ios::binary);
|
||||||
|
if (!input) {
|
||||||
|
return {SkeletonDataFormat::Unknown, VersionDetectionStatus::FileMissing, std::nullopt,
|
||||||
|
"Skeleton data file could not be opened."};
|
||||||
|
}
|
||||||
|
|
||||||
|
char first = '\0';
|
||||||
|
while (input.get(first)) {
|
||||||
|
if (first != ' ' && first != '\t' && first != '\r' && first != '\n') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
input.clear();
|
||||||
|
input.seekg(0);
|
||||||
|
return first == '{' ? inspectJson(input) : inspectBinary(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string toString(SkeletonDataFormat format) {
|
||||||
|
switch (format) {
|
||||||
|
case SkeletonDataFormat::Json: return "json";
|
||||||
|
case SkeletonDataFormat::Binary: return "skel";
|
||||||
|
case SkeletonDataFormat::Unknown: return "unknown";
|
||||||
|
}
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string toString(VersionDetectionStatus status) {
|
||||||
|
switch (status) {
|
||||||
|
case VersionDetectionStatus::Detected: return "detected";
|
||||||
|
case VersionDetectionStatus::FileMissing: return "file-missing";
|
||||||
|
case VersionDetectionStatus::InvalidData: return "invalid-data";
|
||||||
|
case VersionDetectionStatus::UnsupportedVersion: return "unsupported-version";
|
||||||
|
}
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace dts
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "domain/SpineVersion.hpp"
|
||||||
|
|
||||||
|
#include <filesystem>
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace dts {
|
||||||
|
|
||||||
|
enum class SkeletonDataFormat { Json, Binary, Unknown };
|
||||||
|
|
||||||
|
enum class VersionDetectionStatus {
|
||||||
|
Detected,
|
||||||
|
FileMissing,
|
||||||
|
InvalidData,
|
||||||
|
UnsupportedVersion
|
||||||
|
};
|
||||||
|
|
||||||
|
struct VersionDetectionResult {
|
||||||
|
SkeletonDataFormat format = SkeletonDataFormat::Unknown;
|
||||||
|
VersionDetectionStatus status = VersionDetectionStatus::InvalidData;
|
||||||
|
std::optional<SpineVersion> version;
|
||||||
|
std::string message;
|
||||||
|
};
|
||||||
|
|
||||||
|
class SpineDataVersionDetector {
|
||||||
|
public:
|
||||||
|
[[nodiscard]] VersionDetectionResult inspect(const std::filesystem::path& path) const;
|
||||||
|
};
|
||||||
|
|
||||||
|
[[nodiscard]] std::string toString(SkeletonDataFormat format);
|
||||||
|
[[nodiscard]] std::string toString(VersionDetectionStatus status);
|
||||||
|
|
||||||
|
} // namespace dts
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "preview/RuntimePlugin.hpp"
|
||||||
|
|
||||||
|
#include <QMainWindow>
|
||||||
|
#include <QHash>
|
||||||
|
#include <QStringList>
|
||||||
|
|
||||||
|
class QComboBox;
|
||||||
|
class QCheckBox;
|
||||||
|
class QLabel;
|
||||||
|
class QPushButton;
|
||||||
|
class QTimer;
|
||||||
|
|
||||||
|
namespace dts {
|
||||||
|
|
||||||
|
class SpinePreviewWidget;
|
||||||
|
class ConversionJob;
|
||||||
|
|
||||||
|
class MainWindow final : public QMainWindow {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit MainWindow(QWidget* parent = nullptr);
|
||||||
|
void openResourcePath(const QString& path);
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void dragEnterEvent(QDragEnterEvent* event) override;
|
||||||
|
void dropEvent(QDropEvent* event) override;
|
||||||
|
|
||||||
|
private slots:
|
||||||
|
void chooseResources();
|
||||||
|
void resourceChanged(int index);
|
||||||
|
void convertCurrent();
|
||||||
|
void conversionFinished(bool success, const QString& outputDirectory,
|
||||||
|
const QString& error);
|
||||||
|
void animationChanged(const QString& name);
|
||||||
|
void advancePreview();
|
||||||
|
|
||||||
|
private:
|
||||||
|
void setupUi();
|
||||||
|
void addInput(const QString& path);
|
||||||
|
void addSkeleton(const QString& path);
|
||||||
|
void loadCurrentPreview();
|
||||||
|
QString findAtlas(const QString& skeletonPath) const;
|
||||||
|
QString runtimeLibrary(const QString& versionLine) const;
|
||||||
|
QString resolveEditor();
|
||||||
|
void scheduleAvailabilityCheck();
|
||||||
|
void refreshAvailability();
|
||||||
|
bool chooseEditorPath();
|
||||||
|
void showAvailability(const QString& text, const QString& color);
|
||||||
|
void showEmptyState();
|
||||||
|
void setBusy(bool busy);
|
||||||
|
|
||||||
|
QComboBox* resourceCombo_ = nullptr;
|
||||||
|
QComboBox* animationCombo_ = nullptr;
|
||||||
|
QCheckBox* unpremultiplyCheck_ = nullptr;
|
||||||
|
SpinePreviewWidget* previewWidget_ = nullptr;
|
||||||
|
QLabel* statusLabel_ = nullptr;
|
||||||
|
QLabel* availabilityLabel_ = nullptr;
|
||||||
|
QPushButton* convertButton_ = nullptr;
|
||||||
|
QStringList skeletonPaths_;
|
||||||
|
QString currentVersion_;
|
||||||
|
QString currentEditorVersion_;
|
||||||
|
QString editorPath_;
|
||||||
|
QHash<QString, QString> availableVersions_;
|
||||||
|
bool availabilityCheckScheduled_ = false;
|
||||||
|
RuntimePlugin runtime_;
|
||||||
|
ConversionJob* conversionJob_ = nullptr;
|
||||||
|
QTimer* previewTimer_ = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace dts
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
#include "ui/SpinePreviewWidget.hpp"
|
||||||
|
|
||||||
|
#include <QApplication>
|
||||||
|
#include <QColor>
|
||||||
|
#include <QMouseEvent>
|
||||||
|
#include <QPainter>
|
||||||
|
#include <QPainterPath>
|
||||||
|
#include <QPolygonF>
|
||||||
|
#include <QTransform>
|
||||||
|
#include <QWheelEvent>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <limits>
|
||||||
|
|
||||||
|
namespace dts {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
QString localString(const std::filesystem::path& path) {
|
||||||
|
#if defined(_WIN32)
|
||||||
|
return QString::fromStdWString(path.wstring());
|
||||||
|
#else
|
||||||
|
const auto text = path.u8string();
|
||||||
|
return QString::fromUtf8(reinterpret_cast<const char*>(text.data()),
|
||||||
|
static_cast<qsizetype>(text.size()));
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
QPainter::CompositionMode compositionMode(std::uint32_t blendMode) {
|
||||||
|
switch (blendMode) {
|
||||||
|
case 1: return QPainter::CompositionMode_Plus;
|
||||||
|
case 2: return QPainter::CompositionMode_Multiply;
|
||||||
|
case 3: return QPainter::CompositionMode_Screen;
|
||||||
|
default: return QPainter::CompositionMode_SourceOver;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
SpinePreviewWidget::SpinePreviewWidget(QWidget* parent) : QWidget(parent) {
|
||||||
|
setMinimumSize(640, 440);
|
||||||
|
setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
|
||||||
|
setAutoFillBackground(false);
|
||||||
|
setMouseTracking(true);
|
||||||
|
setToolTip(tr("滚轮缩放 · 中键重置 · 右键拖动 · 右键单击居中"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void SpinePreviewWidget::setFrame(std::vector<PreviewDrawCommand> commands) {
|
||||||
|
if (!viewBounds_ && !commands.empty()) {
|
||||||
|
float left = std::numeric_limits<float>::max();
|
||||||
|
float right = std::numeric_limits<float>::lowest();
|
||||||
|
float bottom = std::numeric_limits<float>::max();
|
||||||
|
float top = std::numeric_limits<float>::lowest();
|
||||||
|
for (const auto& command : commands) for (const auto& vertex : command.vertices) {
|
||||||
|
left = std::min(left, vertex.x);
|
||||||
|
right = std::max(right, vertex.x);
|
||||||
|
bottom = std::min(bottom, vertex.y);
|
||||||
|
top = std::max(top, vertex.y);
|
||||||
|
}
|
||||||
|
if (right > left && top > bottom)
|
||||||
|
viewBounds_ = QRectF(left, bottom, right - left, top - bottom);
|
||||||
|
}
|
||||||
|
commands_ = std::move(commands);
|
||||||
|
message_.clear();
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
void SpinePreviewWidget::resetViewport() {
|
||||||
|
viewBounds_.reset();
|
||||||
|
zoom_ = 1.0F;
|
||||||
|
pan_ = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
void SpinePreviewWidget::setMessage(const QString& message) {
|
||||||
|
commands_.clear();
|
||||||
|
message_ = message;
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
const QImage& SpinePreviewWidget::texture(const std::filesystem::path& path) {
|
||||||
|
const auto key = localString(path);
|
||||||
|
auto found = textures_.find(key);
|
||||||
|
if (found == textures_.end()) found = textures_.insert(key, QImage(key));
|
||||||
|
return found.value();
|
||||||
|
}
|
||||||
|
|
||||||
|
void SpinePreviewWidget::paintEvent(QPaintEvent*) {
|
||||||
|
QPainter painter(this);
|
||||||
|
painter.setRenderHint(QPainter::SmoothPixmapTransform);
|
||||||
|
painter.fillRect(rect(), QColor("#101217"));
|
||||||
|
if (commands_.empty()) {
|
||||||
|
painter.setPen(QColor("#a7adb8"));
|
||||||
|
painter.drawText(rect(), Qt::AlignCenter | Qt::TextWordWrap, message_);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QRectF bounds = viewBounds_.value_or(QRectF(0, 0, 1, 1));
|
||||||
|
const float contentWidth = std::max(1.0, bounds.width());
|
||||||
|
const float contentHeight = std::max(1.0, bounds.height());
|
||||||
|
const float baseScale = 0.94F * std::min(width() / contentWidth, height() / contentHeight);
|
||||||
|
const float scale = baseScale * zoom_;
|
||||||
|
const QPointF center = bounds.center();
|
||||||
|
const auto toScreen = [this, center, scale](const PreviewVertex& vertex) {
|
||||||
|
return QPointF(width() * 0.5 + pan_.x() + (vertex.x - center.x()) * scale,
|
||||||
|
height() * 0.5 + pan_.y() + (vertex.y - center.y()) * scale);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const auto& command : commands_) {
|
||||||
|
const auto& image = texture(command.texturePath);
|
||||||
|
if (image.isNull()) continue;
|
||||||
|
painter.setCompositionMode(compositionMode(command.blendMode));
|
||||||
|
for (std::size_t index = 0; index + 2 < command.indices.size(); index += 3) {
|
||||||
|
const auto i0 = command.indices[index];
|
||||||
|
const auto i1 = command.indices[index + 1];
|
||||||
|
const auto i2 = command.indices[index + 2];
|
||||||
|
if (i0 >= command.vertices.size() || i1 >= command.vertices.size()
|
||||||
|
|| i2 >= command.vertices.size()) continue;
|
||||||
|
const auto& v0 = command.vertices[i0];
|
||||||
|
const auto& v1 = command.vertices[i1];
|
||||||
|
const auto& v2 = command.vertices[i2];
|
||||||
|
const QPointF s0(v0.u * image.width(), v0.v * image.height());
|
||||||
|
const QPointF s1(v1.u * image.width(), v1.v * image.height());
|
||||||
|
const QPointF s2(v2.u * image.width(), v2.v * image.height());
|
||||||
|
const QPointF d0 = toScreen(v0);
|
||||||
|
const QPointF d1 = toScreen(v1);
|
||||||
|
const QPointF d2 = toScreen(v2);
|
||||||
|
QPolygonF source{s0, s1, s2, s1 + s2 - s0};
|
||||||
|
QPolygonF destination{d0, d1, d2, d1 + d2 - d0};
|
||||||
|
QTransform transform;
|
||||||
|
if (!QTransform::quadToQuad(source, destination, transform)) continue;
|
||||||
|
QPainterPath clip;
|
||||||
|
clip.addPolygon(QPolygonF{d0, d1, d2});
|
||||||
|
painter.save();
|
||||||
|
painter.setClipPath(clip);
|
||||||
|
painter.setOpacity(QColor::fromRgba(v0.color).alphaF());
|
||||||
|
painter.setTransform(transform);
|
||||||
|
painter.drawImage(QPointF(0, 0), image);
|
||||||
|
painter.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SpinePreviewWidget::wheelEvent(QWheelEvent* event) {
|
||||||
|
if (commands_.empty() || event->angleDelta().y() == 0) return;
|
||||||
|
const float previousZoom = zoom_;
|
||||||
|
const float factor = event->angleDelta().y() > 0 ? 1.12F : 1.0F / 1.12F;
|
||||||
|
zoom_ = std::clamp(previousZoom * factor, 0.15F, 8.0F);
|
||||||
|
const QPointF canvasCenter(width() * 0.5, height() * 0.5);
|
||||||
|
const QPointF cursorOffset = event->position() - canvasCenter;
|
||||||
|
pan_ = cursorOffset - (cursorOffset - pan_) * (zoom_ / previousZoom);
|
||||||
|
update();
|
||||||
|
event->accept();
|
||||||
|
}
|
||||||
|
|
||||||
|
void SpinePreviewWidget::mousePressEvent(QMouseEvent* event) {
|
||||||
|
if (event->button() == Qt::MiddleButton) {
|
||||||
|
zoom_ = 1.0F;
|
||||||
|
pan_ = {};
|
||||||
|
update();
|
||||||
|
event->accept();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event->button() == Qt::RightButton) {
|
||||||
|
rightPressPosition_ = event->position();
|
||||||
|
lastMousePosition_ = event->position();
|
||||||
|
rightDragging_ = false;
|
||||||
|
setCursor(Qt::ClosedHandCursor);
|
||||||
|
event->accept();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SpinePreviewWidget::mouseMoveEvent(QMouseEvent* event) {
|
||||||
|
if (!(event->buttons() & Qt::RightButton)) return;
|
||||||
|
if (!rightDragging_ && (event->position() - rightPressPosition_).manhattanLength()
|
||||||
|
>= QApplication::startDragDistance()) rightDragging_ = true;
|
||||||
|
if (rightDragging_) {
|
||||||
|
pan_ += event->position() - lastMousePosition_;
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
lastMousePosition_ = event->position();
|
||||||
|
event->accept();
|
||||||
|
}
|
||||||
|
|
||||||
|
void SpinePreviewWidget::mouseReleaseEvent(QMouseEvent* event) {
|
||||||
|
if (event->button() != Qt::RightButton) return;
|
||||||
|
unsetCursor();
|
||||||
|
if (!rightDragging_) {
|
||||||
|
pan_ = {};
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
rightDragging_ = false;
|
||||||
|
event->accept();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace dts
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "preview/RuntimePlugin.hpp"
|
||||||
|
|
||||||
|
#include <QHash>
|
||||||
|
#include <QImage>
|
||||||
|
#include <QPointF>
|
||||||
|
#include <QRectF>
|
||||||
|
#include <QWidget>
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
|
||||||
|
namespace dts {
|
||||||
|
|
||||||
|
class SpinePreviewWidget final : public QWidget {
|
||||||
|
public:
|
||||||
|
explicit SpinePreviewWidget(QWidget* parent = nullptr);
|
||||||
|
|
||||||
|
void setFrame(std::vector<PreviewDrawCommand> commands);
|
||||||
|
void setMessage(const QString& message);
|
||||||
|
void resetViewport();
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void paintEvent(QPaintEvent* event) override;
|
||||||
|
void wheelEvent(QWheelEvent* event) override;
|
||||||
|
void mousePressEvent(QMouseEvent* event) override;
|
||||||
|
void mouseMoveEvent(QMouseEvent* event) override;
|
||||||
|
void mouseReleaseEvent(QMouseEvent* event) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
const QImage& texture(const std::filesystem::path& path);
|
||||||
|
|
||||||
|
std::vector<PreviewDrawCommand> commands_;
|
||||||
|
QHash<QString, QImage> textures_;
|
||||||
|
QString message_;
|
||||||
|
std::optional<QRectF> viewBounds_;
|
||||||
|
float zoom_ = 1.0F;
|
||||||
|
QPointF pan_;
|
||||||
|
QPointF rightPressPosition_;
|
||||||
|
QPointF lastMousePosition_;
|
||||||
|
bool rightDragging_ = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace dts
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
#include "atlas/AtlasParser.hpp"
|
||||||
|
#include "domain/SpineVersion.hpp"
|
||||||
|
#include "editor/SpineEditorLocator.hpp"
|
||||||
|
#include "editor/SpineOutputParser.hpp"
|
||||||
|
#include "editor/SpineVersionAvailability.hpp"
|
||||||
|
#include "platform/ProcessRunner.hpp"
|
||||||
|
#include "preview/RuntimePlugin.hpp"
|
||||||
|
#include "scan/SpineDataVersionDetector.hpp"
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <fstream>
|
||||||
|
#include <iostream>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
int failures = 0;
|
||||||
|
|
||||||
|
void expect(bool condition, const std::string& message) {
|
||||||
|
if (!condition) {
|
||||||
|
std::cerr << "FAILED: " << message << '\n';
|
||||||
|
++failures;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void testVersions() {
|
||||||
|
const auto supported = dts::SpineVersion::parse("3.8.99");
|
||||||
|
expect(supported.has_value(), "3.8.99 parses");
|
||||||
|
expect(supported && supported->line() == "3.8", "version line is 3.8");
|
||||||
|
expect(supported && supported->isSupported(), "3.8.99 is supported");
|
||||||
|
|
||||||
|
const auto early = dts::SpineVersion::parse("3.8.19");
|
||||||
|
expect(early && !early->isSupported(), "3.8.19 is outside formal support");
|
||||||
|
const auto current = dts::SpineVersion::parse("4.3.6");
|
||||||
|
expect(current && current->isSupported(), "4.3 is supported");
|
||||||
|
const auto beta = dts::SpineVersion::parse("4.3.75-beta");
|
||||||
|
expect(beta && beta->toString() == "4.3.75", "version suffix is accepted");
|
||||||
|
const auto old = dts::SpineVersion::parse("3.7.94");
|
||||||
|
expect(old && !old->isSupported(), "3.7 is unsupported");
|
||||||
|
expect(!dts::SpineVersion::parse("four.two"), "invalid version is rejected");
|
||||||
|
}
|
||||||
|
|
||||||
|
void testLauncherOutput() {
|
||||||
|
const std::string output =
|
||||||
|
"Spine Launcher 4.3.06 (macOS Apple Silicon)\n"
|
||||||
|
"Launching: Spine 3.8.99 Professional\n"
|
||||||
|
"Starting: Spine 3.8.99\n";
|
||||||
|
dts::ProcessResult process{true, false, 0, output, {}};
|
||||||
|
const auto result = dts::SpineOutputParser{}.parse("/Applications/Spine", process);
|
||||||
|
expect(result.status == dts::EditorProbeStatus::Available, "probe is available");
|
||||||
|
expect(result.launcherVersion && result.launcherVersion->toString() == "4.3.6",
|
||||||
|
"launcher version is parsed separately");
|
||||||
|
expect(result.editorVersion && result.editorVersion->toString() == "3.8.99",
|
||||||
|
"editor version is parsed separately");
|
||||||
|
expect(result.edition == "Professional", "edition is parsed");
|
||||||
|
}
|
||||||
|
|
||||||
|
void testTimedOutOutput() {
|
||||||
|
dts::ProcessResult process;
|
||||||
|
process.started = true;
|
||||||
|
process.timedOut = true;
|
||||||
|
process.exitCode = 143;
|
||||||
|
process.output = "Spine Launcher 4.3.06\nLaunching: Spine 4.2.83 Professional\n";
|
||||||
|
const auto result = dts::SpineOutputParser{}.parse("Spine", process);
|
||||||
|
expect(result.status == dts::EditorProbeStatus::ObservedButTimedOut,
|
||||||
|
"recognized timeout is distinguished from missing Spine");
|
||||||
|
}
|
||||||
|
|
||||||
|
void testMacBundleNormalization() {
|
||||||
|
#if defined(__APPLE__)
|
||||||
|
const auto path = dts::SpineEditorLocator{}.normalize("/Applications/Spine.app");
|
||||||
|
expect(path.has_value(), "macOS .app is normalized");
|
||||||
|
expect(path && path->filename() == "Spine", "normalized executable is Spine");
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void testProcessTimeout() {
|
||||||
|
#if !defined(_WIN32)
|
||||||
|
auto runner = dts::createDefaultProcessRunner();
|
||||||
|
dts::ProcessRequest request;
|
||||||
|
request.program = "/bin/sleep";
|
||||||
|
request.arguments = {"5"};
|
||||||
|
request.timeout = std::chrono::milliseconds(50);
|
||||||
|
const auto result = runner->run(request);
|
||||||
|
expect(result.started, "timeout test process starts");
|
||||||
|
expect(result.timedOut, "long-running process is timed out");
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void writeVarint(std::ofstream& output, std::size_t value) {
|
||||||
|
while (true) {
|
||||||
|
auto byte = static_cast<unsigned char>(value & 0x7f);
|
||||||
|
value >>= 7;
|
||||||
|
if (value != 0) {
|
||||||
|
byte |= 0x80;
|
||||||
|
}
|
||||||
|
output.put(static_cast<char>(byte));
|
||||||
|
if (value == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void writeBinaryString(std::ofstream& output, const std::string& value) {
|
||||||
|
writeVarint(output, value.size() + 1);
|
||||||
|
output.write(value.data(), static_cast<std::streamsize>(value.size()));
|
||||||
|
}
|
||||||
|
|
||||||
|
void testDataVersionDetection() {
|
||||||
|
const auto directory = std::filesystem::temp_directory_path() / "dts-core-tests";
|
||||||
|
std::filesystem::create_directories(directory);
|
||||||
|
|
||||||
|
const auto jsonPath = directory / "skeleton.json";
|
||||||
|
{
|
||||||
|
std::ofstream output(jsonPath);
|
||||||
|
output << R"({"skeleton":{"spine":"4.2.17"},"bones":[{"name":"root"}]})";
|
||||||
|
}
|
||||||
|
const auto json = dts::SpineDataVersionDetector{}.inspect(jsonPath);
|
||||||
|
expect(json.status == dts::VersionDetectionStatus::Detected, "Spine JSON is detected");
|
||||||
|
expect(json.version && json.version->toString() == "4.2.17", "JSON version is parsed");
|
||||||
|
|
||||||
|
const auto binaryPath = directory / "skeleton.skel";
|
||||||
|
{
|
||||||
|
std::ofstream output(binaryPath, std::ios::binary);
|
||||||
|
writeBinaryString(output, "fixture-hash");
|
||||||
|
writeBinaryString(output, "3.8.99");
|
||||||
|
}
|
||||||
|
const auto binary = dts::SpineDataVersionDetector{}.inspect(binaryPath);
|
||||||
|
expect(binary.status == dts::VersionDetectionStatus::Detected, "SKEL is detected");
|
||||||
|
expect(binary.version && binary.version->toString() == "3.8.99", "SKEL version is parsed");
|
||||||
|
|
||||||
|
const auto genericPath = directory / "generic.json";
|
||||||
|
{
|
||||||
|
std::ofstream output(genericPath);
|
||||||
|
output << R"({"spine":"4.2.0","items":[]})";
|
||||||
|
}
|
||||||
|
const auto generic = dts::SpineDataVersionDetector{}.inspect(genericPath);
|
||||||
|
expect(generic.status == dts::VersionDetectionStatus::InvalidData,
|
||||||
|
"ordinary JSON is not accepted as Spine data");
|
||||||
|
std::filesystem::remove_all(directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
void testAtlasParsing() {
|
||||||
|
const std::string atlas =
|
||||||
|
"hero.webp\n"
|
||||||
|
"size: 1024, 512\n"
|
||||||
|
"filter: Linear, Linear\n"
|
||||||
|
"scale: 0.5\n"
|
||||||
|
"pma: true\n"
|
||||||
|
"body/arm\n"
|
||||||
|
"bounds: 10, 20, 30, 40\n"
|
||||||
|
"offsets: 1, 2, 32, 44\n"
|
||||||
|
"rotate: 90\n"
|
||||||
|
"\n"
|
||||||
|
"hero-2.jpg\n"
|
||||||
|
"size: 256, 256\n"
|
||||||
|
"face\n"
|
||||||
|
"xy: 4, 5\n"
|
||||||
|
"size: 20, 30\n"
|
||||||
|
"orig: 22, 34\n";
|
||||||
|
const auto result = dts::AtlasParser{}.parseText(atlas);
|
||||||
|
expect(result.document.has_value(), "multi-page Atlas parses");
|
||||||
|
expect(result.document && result.document->pages.size() == 2, "two pages are found");
|
||||||
|
const auto& first = result.document->pages.front();
|
||||||
|
expect(first.imagePath == "hero.webp", "WebP page path is retained");
|
||||||
|
expect(first.premultipliedAlpha == true, "PMA is parsed");
|
||||||
|
expect(first.scale && std::abs(*first.scale - 0.5) < 0.000001,
|
||||||
|
"Atlas page scale is parsed");
|
||||||
|
expect(first.regions.size() == 1 && first.regions.front().rotated,
|
||||||
|
"rotated region is parsed");
|
||||||
|
const auto& second = result.document->pages.back();
|
||||||
|
expect(second.regions.size() == 1 && second.regions.front().size.size() == 2,
|
||||||
|
"legacy region size is parsed");
|
||||||
|
}
|
||||||
|
|
||||||
|
void testInstalledVersionDiscovery() {
|
||||||
|
const auto directory = std::filesystem::temp_directory_path() / "dts-version-cache-tests";
|
||||||
|
std::filesystem::create_directories(directory);
|
||||||
|
for (const auto* name : {"4.3.18", "4.3.23", "4.2.43", "not-a-version"}) {
|
||||||
|
std::ofstream output(directory / name, std::ios::binary);
|
||||||
|
output.put('x');
|
||||||
|
}
|
||||||
|
dts::SpineVersionAvailability availability;
|
||||||
|
const auto selected = availability.newestForLine("4.3", {directory});
|
||||||
|
expect(selected && selected->toString() == "4.3.23",
|
||||||
|
"newest locally cached patch is selected for a version line");
|
||||||
|
expect(selected && availability.isCached(*selected, {directory}),
|
||||||
|
"selected exact editor version is confirmed in local cache");
|
||||||
|
expect(!availability.newestForLine("4.1", {directory}),
|
||||||
|
"missing cached version line is not reported as available");
|
||||||
|
std::filesystem::remove_all(directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
void expectRuntimePlugin(const std::string& path, const std::string& versionLine) {
|
||||||
|
dts::RuntimePlugin plugin;
|
||||||
|
std::string error;
|
||||||
|
expect(plugin.load(path, error), versionLine + " Runtime plugin loads: " + error);
|
||||||
|
const auto metadata = plugin.metadata(error);
|
||||||
|
expect(metadata.has_value(), "Runtime metadata is available: " + error);
|
||||||
|
expect(metadata && metadata->apiVersion == DTS_RUNTIME_API_VERSION,
|
||||||
|
"Runtime API version matches");
|
||||||
|
expect(metadata && metadata->spineVersionLine == versionLine,
|
||||||
|
"Runtime line is " + versionLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
void testRuntimePlugins() {
|
||||||
|
#if defined(DTS_RUNTIME_38_PATH)
|
||||||
|
expectRuntimePlugin(DTS_RUNTIME_38_PATH, "3.8");
|
||||||
|
expectRuntimePlugin(DTS_RUNTIME_40_PATH, "4.0");
|
||||||
|
expectRuntimePlugin(DTS_RUNTIME_41_PATH, "4.1");
|
||||||
|
expectRuntimePlugin(DTS_RUNTIME_42_PATH, "4.2");
|
||||||
|
expectRuntimePlugin(DTS_RUNTIME_43_PATH, "4.3");
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void testRuntime43OfficialAssets() {
|
||||||
|
#if defined(DTS_TEST_SPINEBOY_JSON)
|
||||||
|
const auto jsonVersion = dts::SpineDataVersionDetector{}.inspect(DTS_TEST_SPINEBOY_JSON);
|
||||||
|
expect(jsonVersion.status == dts::VersionDetectionStatus::Detected,
|
||||||
|
"official 4.3 beta JSON version is detected");
|
||||||
|
const auto binaryVersion = dts::SpineDataVersionDetector{}.inspect(DTS_TEST_SPINEBOY_SKEL);
|
||||||
|
expect(binaryVersion.status == dts::VersionDetectionStatus::Detected,
|
||||||
|
"official 4.3 binary header version is detected");
|
||||||
|
dts::RuntimePlugin plugin;
|
||||||
|
std::string error;
|
||||||
|
expect(plugin.load(DTS_RUNTIME_43_PATH, error), "4.3 Runtime loads for official assets");
|
||||||
|
const auto json = plugin.loadSkeleton(
|
||||||
|
DTS_TEST_SPINEBOY_JSON, DTS_TEST_SPINEBOY_ATLAS, 1.0F, error);
|
||||||
|
expect(json.has_value(), "official Spineboy JSON loads: " + error);
|
||||||
|
expect(json && json->boneCount > 0, "Spineboy JSON contains bones");
|
||||||
|
expect(json && !json->animations.empty(), "Spineboy JSON contains animations");
|
||||||
|
if (json && !json->animations.empty()) {
|
||||||
|
const auto idle = std::find_if(json->animations.begin(), json->animations.end(),
|
||||||
|
[](const auto& animation) { return animation.name == "idle"; });
|
||||||
|
const auto& animated = idle == json->animations.end()
|
||||||
|
? json->animations.front() : *idle;
|
||||||
|
expect(plugin.setAnimation(animated.name, true, error),
|
||||||
|
"Spineboy animation can be selected: " + error);
|
||||||
|
const auto frame = plugin.updatePreview(1.0F / 60.0F, error);
|
||||||
|
expect(frame.has_value(), "Spineboy preview frame is sampled: " + error);
|
||||||
|
expect(frame && !frame->empty(), "Spineboy preview contains draw commands");
|
||||||
|
if (frame && !frame->empty()) {
|
||||||
|
expect(!frame->front().vertices.empty(), "draw command contains vertices");
|
||||||
|
expect(!frame->front().indices.empty(), "draw command contains triangles");
|
||||||
|
expect(std::filesystem::exists(frame->front().texturePath),
|
||||||
|
"draw command texture exists");
|
||||||
|
}
|
||||||
|
const auto laterFrame = plugin.updatePreview(0.25F, error);
|
||||||
|
bool moved = false;
|
||||||
|
if (frame && laterFrame && frame->size() == laterFrame->size()) {
|
||||||
|
for (std::size_t command = 0; command < frame->size() && !moved; ++command) {
|
||||||
|
const auto& before = (*frame)[command].vertices;
|
||||||
|
const auto& after = (*laterFrame)[command].vertices;
|
||||||
|
for (std::size_t vertex = 0; vertex < std::min(before.size(), after.size()); ++vertex) {
|
||||||
|
if (std::abs(before[vertex].x - after[vertex].x) > 0.001F
|
||||||
|
|| std::abs(before[vertex].y - after[vertex].y) > 0.001F) {
|
||||||
|
moved = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(moved, "animation sampling changes vertex positions over time");
|
||||||
|
}
|
||||||
|
|
||||||
|
error.clear();
|
||||||
|
const auto binary = plugin.loadSkeleton(
|
||||||
|
DTS_TEST_SPINEBOY_SKEL, DTS_TEST_SPINEBOY_ATLAS, 1.0F, error);
|
||||||
|
expect(binary.has_value(), "official Spineboy SKEL loads: " + error);
|
||||||
|
expect(binary && binary->boneCount > 0, "Spineboy SKEL contains bones");
|
||||||
|
expect(binary && !binary->animations.empty(), "Spineboy SKEL contains animations");
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void testRuntime42OfficialAssets() {
|
||||||
|
#if defined(DTS_TEST_42_JSON)
|
||||||
|
dts::RuntimePlugin plugin;
|
||||||
|
std::string error;
|
||||||
|
expect(plugin.load(DTS_RUNTIME_42_PATH, error), "4.2 Runtime loads for official assets");
|
||||||
|
const auto json = plugin.loadSkeleton(DTS_TEST_42_JSON, DTS_TEST_42_ATLAS, 1.0F, error);
|
||||||
|
expect(json.has_value(), "official 4.2 Spineboy JSON loads: " + error);
|
||||||
|
expect(json && !json->animations.empty(), "official 4.2 JSON contains animations");
|
||||||
|
if (json && !json->animations.empty()) {
|
||||||
|
expect(plugin.setAnimation(json->animations.front().name, true, error),
|
||||||
|
"4.2 animation can be selected: " + error);
|
||||||
|
const auto frame = plugin.updatePreview(1.0F / 60.0F, error);
|
||||||
|
expect(frame && !frame->empty(), "4.2 preview produces draw commands: " + error);
|
||||||
|
}
|
||||||
|
error.clear();
|
||||||
|
const auto binary = plugin.loadSkeleton(DTS_TEST_42_SKEL, DTS_TEST_42_ATLAS, 1.0F, error);
|
||||||
|
expect(binary.has_value(), "official 4.2 Spineboy SKEL loads: " + error);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void testLegacyOfficialAsset(const char* line, const char* runtimePath,
|
||||||
|
const char* jsonPath, const char* binaryPath, const char* atlasPath) {
|
||||||
|
dts::RuntimePlugin plugin;
|
||||||
|
std::string error;
|
||||||
|
expect(plugin.load(runtimePath, error), std::string(line) + " Runtime loads for assets");
|
||||||
|
const auto json = plugin.loadSkeleton(jsonPath, atlasPath, 1.0F, error);
|
||||||
|
expect(json.has_value(), std::string("official ") + line + " JSON loads: " + error);
|
||||||
|
expect(json && !json->animations.empty(), std::string(line) + " JSON contains animations");
|
||||||
|
if (json && !json->animations.empty()) {
|
||||||
|
expect(plugin.setAnimation(json->animations.front().name, true, error),
|
||||||
|
std::string(line) + " animation can be selected: " + error);
|
||||||
|
const auto first = plugin.updatePreview(1.0F / 60.0F, error);
|
||||||
|
expect(first && !first->empty(), std::string(line) + " preview draws: " + error);
|
||||||
|
const auto later = plugin.updatePreview(0.25F, error);
|
||||||
|
expect(later && !later->empty(), std::string(line) + " later frame draws: " + error);
|
||||||
|
}
|
||||||
|
error.clear();
|
||||||
|
const auto binary = plugin.loadSkeleton(binaryPath, atlasPath, 1.0F, error);
|
||||||
|
expect(binary.has_value(), std::string("official ") + line + " SKEL loads: " + error);
|
||||||
|
}
|
||||||
|
|
||||||
|
void testLegacyOfficialAssets() {
|
||||||
|
#if defined(DTS_TEST_38_JSON)
|
||||||
|
testLegacyOfficialAsset("3.8", DTS_RUNTIME_38_PATH,
|
||||||
|
DTS_TEST_38_JSON, DTS_TEST_38_SKEL, DTS_TEST_38_ATLAS);
|
||||||
|
testLegacyOfficialAsset("4.0", DTS_RUNTIME_40_PATH,
|
||||||
|
DTS_TEST_40_JSON, DTS_TEST_40_SKEL, DTS_TEST_40_ATLAS);
|
||||||
|
testLegacyOfficialAsset("4.1", DTS_RUNTIME_41_PATH,
|
||||||
|
DTS_TEST_41_JSON, DTS_TEST_41_SKEL, DTS_TEST_41_ATLAS);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
testVersions();
|
||||||
|
testLauncherOutput();
|
||||||
|
testTimedOutOutput();
|
||||||
|
testMacBundleNormalization();
|
||||||
|
testProcessTimeout();
|
||||||
|
testDataVersionDetection();
|
||||||
|
testAtlasParsing();
|
||||||
|
testInstalledVersionDiscovery();
|
||||||
|
testRuntimePlugins();
|
||||||
|
testLegacyOfficialAssets();
|
||||||
|
testRuntime42OfficialAssets();
|
||||||
|
testRuntime43OfficialAssets();
|
||||||
|
if (failures == 0) {
|
||||||
|
std::cout << "All core tests passed.\n";
|
||||||
|
}
|
||||||
|
return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE;
|
||||||
|
}
|
||||||
+1
Submodule DateToSpine/third_party/spine-runtimes/3.8 added at 8b4844bd4b
+1
Submodule DateToSpine/third_party/spine-runtimes/4.0 added at 425ce416bb
+1
Submodule DateToSpine/third_party/spine-runtimes/4.1 added at 77a5db0ec6
+1
Submodule DateToSpine/third_party/spine-runtimes/4.2 added at e7dc1435fa
+1
Submodule DateToSpine/third_party/spine-runtimes/4.3 added at 4309c05c28
Reference in New Issue
Block a user