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) { 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(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((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 } }