Files
SpineParticlesWeb/src/views/particle-panel/useParticleExport.ts
T
2026-09-11 10:00:38 +08:00

226 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { computed, markRaw, onBeforeUnmount, onMounted, reactive, ref, watch, watchEffect } from 'vue'
import { Texture } from 'pixi.js'
import { useParticleStore } from '../../store/particleStore'
import type { ParticleSystem } from '../../store/particleStore'
import { defaultConfig, ensureEmitterConfig } from '../../core/particleEmitter'
import NumSlider from '../NumSlider.vue'
import CurveEditor from '../CurveEditor.vue'
import ParticleAttributeControl from '../ParticleAttributeControl.vue'
import ColorGradientEditor from '../ColorGradientEditor.vue'
import ImageResourceCard from '../ImageResourceCard.vue'
import SpineObjectPanel from '../../spine/SpineObjectPanel.vue'
import { downloadEditorConfig, loadEditorConfigFile, loadEditorConfigText } from '../../editor/editorConfig'
import { beginTransfer, readResponseTextWithProgress } from '../../editor/transferProgress'
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'
export function useParticleExport(store: ReturnType<typeof useParticleStore>) {
store.settings.exportSettings = ensureSpineExportSettings(store.settings.exportSettings)
const exportSettings = computed(() => store.settings.exportSettings)
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 connectorExportErrorCode = 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} 工程`
})
onMounted(() => {
refreshConnectorHealth()
connectorHealthTimer = window.setInterval(refreshConnectorHealth, 3000)
})
let connectorHealthTimer: number | undefined
let connectorHealthRequestId = 0
onBeforeUnmount(() => {
if (connectorHealthTimer !== undefined) window.clearInterval(connectorHealthTimer)
})
async function refreshConnectorHealth() {
const requestId = ++connectorHealthRequestId
const health = await checkSpineConnector()
if (requestId !== connectorHealthRequestId) return
connectorHealth.value = health
const code = connectorExportErrorCode.value
const resolved = code === 'CONNECTOR_OFFLINE'
? health.available
: code === 'SPINE_NOT_FOUND'
? health.available && health.spineFound
: code === 'ORIGIN_NOT_ALLOWED'
? health.available && health.spineFound && health.authorized
: !!code && health.available && health.spineFound && health.authorized
if (resolved) {
connectorExportErrorCode.value = ''
connectorPairUrl.value = ''
if (exportError.value) {
exportError.value = false
exportMessage.value = ''
}
}
}
function nextPaint() {
return new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
}
async function prepareExportFrames(onProgress?: (progress: number) => void) {
store.timeline.playing = false
store.timeline.frame = 0
store.timeline.recorded = false
for (const system of store.systems) system.frames = undefined
store.recalcTotalFrames()
await nextPaint()
store.timeline.playing = true
const expectedMs = store.timeline.totalFrames / Math.max(1, store.timeline.fps) * 1000
const deadline = performance.now() + Math.max(8000, expectedMs * 2.5 + 3000)
while (!store.timeline.recorded) {
if (performance.now() > deadline) throw new Error('重新计算动画超时,请确认页面仍在运行后重试')
onProgress?.(Math.min(0.98, store.timeline.frame / Math.max(1, store.timeline.totalFrames - 1)))
await new Promise((resolve) => setTimeout(resolve, 50))
}
onProgress?.(1)
}
async function exportSpineJson() {
if (exportBusy.value) return
exportBusy.value = true
exportTarget.value = 'json'
exportError.value = false
connectorExportErrorCode.value = ''
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)
downloadSpineJson(result, exportSettings.value.fileName)
const warning = result.warnings.length ? `;${result.warnings.length} 条资源提示(缺图仍已导出)` : ''
exportMessage.value = `已导出 Spine ${exportSettings.value.spineVersion}:${result.particleSystemCount} 个粒子系统、${result.animationCount} 个动画、${result.boneCount} 根骨骼${warning}`
} catch (error) {
exportError.value = true
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
connectorExportErrorCode.value = ''
connectorPairUrl.value = ''
exportMessage.value = '正在重新计算粒子动画…'
const transfer = beginTransfer('导出 Spine 包', '正在重新计算粒子动画…')
try {
await transfer.paint()
await prepareExportFrames((progress) => transfer.update(progress * 18, '正在重新计算粒子动画…'))
transfer.update(20, '正在生成 Spine JSON…')
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[],
}, ({ phase, progress }) => {
if (phase === 'images') transfer.update(progress == null ? null : 22 + progress * 18, '正在整理工程图片…')
else if (phase === 'upload') transfer.update(progress == null ? null : 40 + progress * 30, '正在上传数据到 Spine 连接器…')
else if (phase === 'processing') transfer.update(null, 'Spine 正在转换工程,请稍候…')
else transfer.update(progress == null ? null : 82 + progress * 16, '正在下载生成的 Spine 工程包…')
})
transfer.update(99, '正在交给浏览器下载…')
downloadSpineProject(converted.blob, converted.fileName)
exportMessage.value = `已生成 ${converted.fileName}:包含 .spine 工程和 ${converted.imageCount} 张图片,Spine ${converted.spineEditorVersion}`
await refreshConnectorHealth()
await transfer.finish('Spine 工程包已开始下载')
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
exportError.value = true
connectorExportErrorCode.value = error instanceof SpineConnectorError ? error.code : ''
if (error instanceof SpineConnectorError && error.pairUrl) connectorPairUrl.value = error.pairUrl
exportMessage.value = errorMessage
await refreshConnectorHealth()
await transfer.fail(errorMessage)
} finally {
exportBusy.value = false
exportTarget.value = null
}
}
async function exportSpineImages() {
if (exportBusy.value) return
exportBusy.value = true
exportTarget.value = 'images'
exportError.value = false
connectorExportErrorCode.value = ''
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
}
}
return { exportSettings, exportBusy, exportTarget, exportMessage, exportError, connectorHealth, connectorPairUrl, connectorExportErrorCode, connectorStatusText, connectorStatusClass, connectorReady, connectorAuthorizationUrl, connectorDisabledReason, connectorButtonTitle, connectorHealthTimer, connectorHealthRequestId, refreshConnectorHealth, nextPaint, prepareExportFrames, exportSpineJson, exportSpineProject, exportSpineImages }
}