226 lines
11 KiB
TypeScript
226 lines
11 KiB
TypeScript
|
||
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 }
|
||
}
|