spine连接器和导出修改

This commit is contained in:
tianmo committed 2026-09-05 16:20:23 +08:00
1 parent 535f8d7558
commit 0bde824c88
81 files changed
+11800 -1251

No files matched your search

+158 -7
View File
@@ -900,15 +900,32 @@
</template>
</div>
<button type="button" class="export-spine-json" :disabled="exportBusy" :title="exportBusy ? '正在重新计算并烘焙动画' : `导出 Spine ${exportSettings.spineVersion} JSON`" @click="exportSpineJson">
{{ exportBusy ? '正在准备导出…' : '导出Spine Json' }}
</button>
<div v-if="exportMessage" class="export-message" :class="{ error: exportError }">{{ exportMessage }}</div>
<div class="spine-export-actions">
<button type="button" class="export-spine-json" :disabled="exportBusy" :title="exportBusy ? '正在重新计算并烘焙动画' : `导出 Spine ${exportSettings.spineVersion} JSON`" @click="exportSpineJson">
{{ exportBusy && exportTarget === 'json' ? '正在准备导出…' : '导出Spine Json' }}
</button>
<button type="button" class="export-spine-images" :disabled="exportBusy" title="单独下载当前粒子系统使用的图片 ZIP" @click="exportSpineImages">
{{ exportBusy && exportTarget === 'images' ? '正在整理图片…' : '导出 images 包' }}
</button>
<button type="button" class="export-spine-project" :disabled="exportBusy || !connectorReady" :title="connectorButtonTitle" @click="exportSpineProject">
<span>{{ exportBusy && exportTarget === 'spine' ? '正在生成工程…' : '导出 .spine 包' }}</span>
<small v-if="!exportBusy && connectorDisabledReason">{{ connectorDisabledReason }}</small>
</button>
</div>
<div class="connector-status" :class="connectorStatusClass">
<span class="connector-dot"></span>{{ connectorStatusText }}
<a v-if="connectorAuthorizationUrl" :href="connectorAuthorizationUrl" target="_blank" rel="noopener" title="在本机连接器中授权当前网站">授权当前网站</a>
<button type="button" title="重新检测连接器" @click="refreshConnectorHealth">刷新</button>
</div>
<div v-if="exportMessage" class="export-message" :class="{ error: exportError }">
{{ exportMessage }}
<a v-if="connectorPairUrl" :href="connectorPairUrl" target="_blank" rel="noopener" @click="connectorPairUrl = ''">授权当前网站</a>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, markRaw, onMounted, reactive, ref, watchEffect } from 'vue'
import { computed, markRaw, onBeforeUnmount, onMounted, reactive, ref, watchEffect } from 'vue'
import { Texture } from 'pixi.js'
import { useParticleStore } from '../store/particleStore'
import type { ParticleSystem } from '../store/particleStore'
@@ -923,6 +940,14 @@ import { downloadEditorConfig, loadEditorConfigFile, loadEditorConfigText } from
import { defaultSpineExportSettings, ensureSpineExportSettings } from '../export/spineExportSettings'
import { buildSpineJson, downloadSpineJson } from '../export/spineJsonExporter'
import { validateSpineJson } from '../export/spineJsonValidator'
import { buildSpineImagesArchive, downloadSpineImagesArchive } from '../export/spineImagesExporter'
import {
SpineConnectorError,
checkSpineConnector,
convertWithSpineConnector,
downloadSpineProject,
type SpineConnectorHealth,
} from '../export/spineConnectorClient'
import { automaticLoopTransitionFrames } from '../core/loopSegmentTiming'
const store = useParticleStore()
@@ -935,8 +960,43 @@ const presetLoading = ref(false)
const configMessage = ref('')
const configError = ref(false)
const exportBusy = ref(false)
const exportTarget = ref<'json' | 'spine' | 'images' | null>(null)
const exportMessage = ref('')
const exportError = ref(false)
const connectorHealth = ref<SpineConnectorHealth | null>(null)
const connectorPairUrl = ref('')
const connectorStatusText = computed(() => {
const health = connectorHealth.value
if (!health) return '正在检测本地 Spine 连接器…'
if (!health.available) return '本地 Spine 连接器未启动'
if (!health.spineFound) return '连接器已启动,但未找到 Spine'
if (!health.authorized) return '连接器已启动,当前网站尚未授权'
return '连接器可用 · Spine 已就绪'
})
const connectorStatusClass = computed(() => ({
ready: !!connectorHealth.value?.available && !!connectorHealth.value?.spineFound && !!connectorHealth.value?.authorized,
warning: !!connectorHealth.value?.available && (!connectorHealth.value?.spineFound || !connectorHealth.value?.authorized),
}))
const connectorReady = computed(() => !!connectorHealth.value?.available && !!connectorHealth.value?.spineFound && !!connectorHealth.value?.authorized)
const connectorAuthorizationUrl = computed(() => {
const health = connectorHealth.value
if (!health?.available || !health.spineFound || health.authorized || typeof window === 'undefined') return ''
return `http://127.0.0.1:27843/pair?origin=${encodeURIComponent(window.location.origin)}`
})
const connectorDisabledReason = computed(() => {
const health = connectorHealth.value
if (!health) return '检测中'
if (!health.available) return '未启动'
if (!health.spineFound) return '未找到 Spine'
if (!health.authorized) return '未授权'
return ''
})
const connectorButtonTitle = computed(() => {
if (exportBusy.value) return '正在重新计算并生成 Spine 工程'
if (connectorDisabledReason.value) return `暂不可用:${connectorDisabledReason.value}`
return `调用本机 Spine 自动生成 ${exportSettings.value.spineVersion} 工程`
})
function resolvedCurveEditorMode(mode: '跟随导出' | '线性' | '贝塞尔') {
if (mode === '跟随导出') return exportSettings.value.keyframeCurve === 'bezier' ? 'bezier' : 'linear'
@@ -1002,7 +1062,20 @@ async function loadSelectedPreset() {
}
}
onMounted(refreshPresets)
onMounted(() => {
refreshPresets()
refreshConnectorHealth()
connectorHealthTimer = window.setInterval(refreshConnectorHealth, 3000)
})
let connectorHealthTimer: number | undefined
onBeforeUnmount(() => {
if (connectorHealthTimer !== undefined) window.clearInterval(connectorHealthTimer)
})
async function refreshConnectorHealth() {
connectorHealth.value = await checkSpineConnector()
}
function nextPaint() {
return new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
@@ -1027,7 +1100,9 @@ async function prepareExportFrames() {
async function exportSpineJson() {
if (exportBusy.value) return
exportBusy.value = true
exportTarget.value = 'json'
exportError.value = false
connectorPairUrl.value = ''
exportMessage.value = '正在重新计算粒子动画…'
try {
await prepareExportFrames()
@@ -1041,6 +1116,63 @@ async function exportSpineJson() {
exportMessage.value = error instanceof Error ? error.message : String(error)
} finally {
exportBusy.value = false
exportTarget.value = null
}
}
async function exportSpineProject() {
if (exportBusy.value) return
exportBusy.value = true
exportTarget.value = 'spine'
exportError.value = false
connectorPairUrl.value = ''
exportMessage.value = '正在重新计算粒子动画…'
try {
await prepareExportFrames()
const result = buildSpineJson({ systems: store.systems as unknown as ParticleSystem[], timeline: store.timeline, settings: exportSettings.value })
validateSpineJson(result.text, exportSettings.value.spineVersion)
exportMessage.value = '正在整理图片并调用本机 Spine…'
const projectName = exportSettings.value.fileName.replace(/\.json$/i, '') || 'SpineParticle'
const converted = await convertWithSpineConnector({
projectName,
spineVersion: exportSettings.value.spineVersion,
skeletonJson: result.text,
systems: store.systems as unknown as ParticleSystem[],
})
downloadSpineProject(converted.blob, converted.fileName)
exportMessage.value = `已生成 ${converted.fileName}:包含 .spine 工程和 ${converted.imageCount} 张图片,Spine ${converted.spineEditorVersion}`
await refreshConnectorHealth()
} catch (error) {
exportError.value = true
if (error instanceof SpineConnectorError && error.pairUrl) connectorPairUrl.value = error.pairUrl
exportMessage.value = error instanceof Error ? error.message : String(error)
await refreshConnectorHealth()
} finally {
exportBusy.value = false
exportTarget.value = null
}
}
async function exportSpineImages() {
if (exportBusy.value) return
exportBusy.value = true
exportTarget.value = 'images'
exportError.value = false
connectorPairUrl.value = ''
exportMessage.value = '正在整理当前粒子系统使用的图片…'
try {
const archive = await buildSpineImagesArchive({
systems: store.systems as unknown as ParticleSystem[],
imagesPath: exportSettings.value.imagesPath,
})
const fileName = downloadSpineImagesArchive(archive.blob, exportSettings.value.fileName)
exportMessage.value = `已生成 ${fileName}:${archive.folder}/ 内包含 ${archive.imageCount} 张图片`
} catch (error) {
exportError.value = true
exportMessage.value = error instanceof Error ? error.message : String(error)
} finally {
exportBusy.value = false
exportTarget.value = null
}
}
@@ -1960,11 +2092,30 @@ function toggleResourceLock(resourceId: number) {
.export-switches { display: grid; gap: 13px; margin: 4px 0 7px; padding-top: 2px; }
.export-switch { font-weight: 600; }
.export-switch-last { margin-top: 13px; }
.export-spine-json { width: 100%; margin-top: 10px; padding: 9px 12px; flex: 0 0 auto; border: 1px solid #4f4b9a; border-radius: 7px; background: #403b91; color: #f1f0ff; font-size: 12px; font-weight: 700; cursor: pointer; }
.spine-export-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; margin-top: 10px; }
.export-spine-json { width: 100%; padding: 9px 8px; flex: 0 0 auto; border: 1px solid #4f4b9a; border-radius: 7px; background: #403b91; color: #f1f0ff; font-size: 12px; font-weight: 700; cursor: pointer; }
.export-spine-json:hover { border-color: #7774ff; background: #514bc0; }
.export-spine-json:disabled { cursor: wait; opacity: .68; }
.export-spine-project { grid-column: 1 / -1; width: 100%; padding: 7px 8px; border: 1px solid #287d69; border-radius: 7px; background: #196854; color: #edfff9; font-size: 12px; font-weight: 700; cursor: pointer; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; }
.export-spine-project small { color: inherit; font-size: 9px; font-weight: 500; line-height: 1.1; }
.export-spine-project:hover:not(:disabled) { border-color: #47c89f; background: #238267; }
.export-spine-project:disabled { cursor: not-allowed; opacity: .48; }
.export-spine-images { width: 100%; padding: 8px; border: 1px solid #3b5f8b; border-radius: 7px; background: #243e64; color: #eaf3ff; font-size: 12px; font-weight: 700; cursor: pointer; }
.export-spine-images:hover:not(:disabled) { border-color: #6099dc; background: #315581; }
.export-spine-images:disabled { cursor: wait; opacity: .55; }
.connector-status { display: flex; align-items: center; justify-content: center; gap: 5px; padding-top: 6px; color: #717b99; font-size: 10px; }
.connector-status .connector-dot { width: 6px; height: 6px; border-radius: 50%; background: #66718f; }
.connector-status.ready { color: #70d8ae; }
.connector-status.ready .connector-dot { background: #49c993; box-shadow: 0 0 5px #49c99388; }
.connector-status.warning { color: #d4aa67; }
.connector-status.warning .connector-dot { background: #d4aa67; }
.connector-status button { padding: 0 3px; border: 0; background: transparent; color: #8290b6; font-size: 10px; cursor: pointer; }
.connector-status button:hover { color: #c5caff; }
.connector-status a { padding: 0 3px; color: #a8b0ff; font-size: 10px; text-decoration: underline; }
.connector-status a:hover { color: #d8dcff; }
.export-message { padding: 7px 5px 0; color: #70d8ae; font-size: 10px; line-height: 1.45; text-align: center; }
.export-message.error { color: #ff858f; }
.export-message a { display: inline-block; margin-left: 5px; color: #9da7ff; text-decoration: underline; }
/* 外观与资源 参考版式 */
.look-title { font-size: 12px; font-weight: 600; color: #ccd; margin: 10px 0 6px; padding-bottom: 4px; border-bottom: 1px solid #2a2a3c; }
.look-title:first-child { margin-top: 2px; }