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' import { LINEAR_CURVE, FLAT_CURVE, ALPHA_CURVE, RESOURCE_ALPHA_CURVE, TRAIL_ALPHA_CURVE, TRAIL_SHAPE_CURVE } from './particlePanelCurves' import { useParticleExport } from './useParticleExport' import { useProjectPresets } from './useProjectPresets' import { useParticleResources } from './useParticleResources' /** 每个参数面板创建一次状态,子分组共享选择、折叠状态与操作。 */ export function useParticlePanel() { const store = useParticleStore() const { exportSettings, exportBusy, exportTarget, exportMessage, exportError, connectorHealth, connectorPairUrl, connectorExportErrorCode, connectorStatusText, connectorStatusClass, connectorReady, connectorAuthorizationUrl, connectorDisabledReason, connectorButtonTitle, connectorHealthTimer, connectorHealthRequestId, refreshConnectorHealth, nextPaint, prepareExportFrames, exportSpineJson, exportSpineProject, exportSpineImages } = useParticleExport(store) const { configFileInput, selectedPreset, presetOptions, presetLoading, configMessage, configError, saveConfig, onConfigFileChange, refreshPresets, loadSelectedPreset } = useProjectPresets(store) function resolvedCurveEditorMode(mode: '跟随导出' | '线性' | '贝塞尔') { if (mode === '跟随导出') return exportSettings.value.keyframeCurve === 'bezier' ? 'bezier' : 'linear' return mode === '贝塞尔' ? 'bezier' : 'linear' } // 默认场景始终有一个可编辑的粒子系统;热更新保留现有系统时不重复创建。 if (!store.systems.length) store.addSystem() const activeIdx = computed(() => { const i = store.systems.findIndex((s) => s.id === store.activeId) return i >= 0 ? i : 0 }) const sys = computed(() => store.systems[activeIdx.value] || null) const { textureFileInput, sequenceFileInput, pendingResourceId, trailTextureFileInput, pendingTrailResourceId, addTrailResource, openTrailTexturePicker, onTrailRigChange, onTrailResourceBoneCountChange, onTrailResourceRotationChange, onTrailTextureFile, resetTrailTexture, removeTrailResource, rebalanceTrailWeights, setTrailResourceWeight, toggleTrailResourceLock, setTrailColor, commitTrailColor, addImageResource, openTexturePicker, openSequencePicker, onTextureFile, onSequenceFiles, readFileAsDataUrl, resetTexture, normalizeHex, setResourceFixedColor, commitResourceFixedColor, removeImageResource, roundWeight, distributeImageWeights, rebalanceImageWeights, setResourceWeight, toggleResourceLock } = useParticleResources(sys) const automaticLoopDuration = computed(() => sys.value ? automaticLoopTransitionFrames(sys.value.config, store.timeline.fps) : 30) const loopDurationFrames = computed(() => { const config = sys.value?.config if (!config) return 1 return Math.max(1, Math.ceil(Number(config.loopDurationFrames) || 1)) }) const activeObjectType = computed(() => store.activeObjectType) const activeCollider = computed(() => store.activeObjectType === 'collision' ? store.colliders.find((item) => item.id === store.activeObjectId) || null : null) const activePath = computed(() => store.activeObjectType === 'path' ? store.paths.find((item) => item.id === store.activeObjectId) || null : null) const activeSpine = computed(() => store.activeObjectType === 'spine' ? store.spines.find((item) => item.id === store.activeObjectId) || null : null) const canCopyObject = computed(() => { if (store.activeObjectType === 'particle') return store.systems.some((item) => item.id === store.activeObjectId) if (store.activeObjectType === 'collision') return store.colliders.some((item) => item.id === store.activeObjectId) if (store.activeObjectType === 'path') return store.paths.some((item) => item.id === store.activeObjectId) return store.spines.some((item) => item.id === store.activeObjectId) }) const selectedSpineBone = computed(() => { const spine = store.spines.find((item) => item.selectedBone && item.bones.some((bone) => bone.name === item.selectedBone)) return spine ? { spine, boneName: spine.selectedBone } : null }) const boundSpineBone = computed(() => { const config = sys.value?.config if (!config || config.emitterFollowMode !== 'bone') return null const spine = store.spines.find((item) => item.id === config.emitterFollowSpineId) if (!spine || !spine.bones.some((bone) => bone.name === config.emitterFollowBoneName)) return null return { spine, boneName: config.emitterFollowBoneName } }) const collisionPickerOpen = ref(false) const selectedCollisionBodies = computed(() => { const ids = new Set(sys.value?.config.collisionColliderIds || []) return store.colliders.filter((collider) => ids.has(collider.id)) }) const availableCollisionBodies = computed(() => { const ids = new Set(sys.value?.config.collisionColliderIds || []) return store.colliders.filter((collider) => !ids.has(collider.id)) }) watch(() => [sys.value?.id, sys.value?.config.collision], () => { collisionPickerOpen.value = false }) function addCollisionBody(colliderId: number) { const config = sys.value?.config if (!config || config.collisionColliderIds.includes(colliderId)) return config.collisionColliderIds.push(colliderId) collisionPickerOpen.value = false } function removeCollisionBody(colliderId: number) { const config = sys.value?.config if (!config) return config.collisionColliderIds = config.collisionColliderIds.filter((id) => id !== colliderId) } // 兼容开发热更新和旧场景:缺少显隐字段的对象按“显示”处理。 watchEffect(() => { for (const system of store.systems) if (system.visible == null) system.visible = true for (const collider of store.colliders) if (collider.visible == null) collider.visible = true for (const path of store.paths) if (path.visible == null) path.visible = true for (const spine of store.spines) if (spine.visible == null) spine.visible = true }) // 开发热更新会保留旧的 Pinia 对象;渲染属性控件前先补齐新增字段。 watchEffect(() => { if (!sys.value) return const config = ensureEmitterConfig(sys.value.config as any) // 初始属性不再提供曲线模式;旧配置自动回退为随机。 if (config.lifeMode === 'curve') config.lifeMode = 'random' if (config.speedMode === 'curve') config.speedMode = 'random' if (config.scaleMode === 'curve') config.scaleMode = 'random' if (config.initialRotationMode === 'curve') config.initialRotationMode = 'random' if (config.mode === 'stream' && (!(config.duration > 0))) config.duration = 1 if (config.streamBehavior === 'loop') { if (config.emitterFollow || config.emitterFollowMode !== 'none') resetParticleFollow(config) } }) // 每个粒子系统永久保存自己的 Spine ID 与骨骼名;层级树当前选择不会覆盖既有绑定。 // 仅当已绑定的 Spine 或骨骼确实不存在时,才回到“无”模式。 watchEffect(() => { const colliderIds = new Set(store.colliders.map((collider) => collider.id)) for (const system of store.systems) { const config = ensureEmitterConfig(system.config as any) const validCollisionIds = config.collisionColliderIds.filter((id) => colliderIds.has(id)) if (validCollisionIds.length !== config.collisionColliderIds.length) config.collisionColliderIds = validCollisionIds if (config.spawnPathId > 0 && !store.paths.some((path) => path.id === config.spawnPathId)) { config.spawnPathId = 0 if (config.emissionAngleMode === 'path-normal' || config.emissionAngleMode === 'path-tangent') config.emissionAngleMode = 'fixed' } if (config.emitterFollowMode !== 'bone') continue const spine = store.spines.find((item) => item.id === config.emitterFollowSpineId) const targetExists = !!spine?.bones.some((bone) => bone.name === config.emitterFollowBoneName) if (!targetExists) { config.emitterFollowMode = 'none' config.emitterFollowSpineId = 0 config.emitterFollowBoneName = '' } } }) // HMR 或旧场景中的碰撞体缺少路径跟随字段时即时补齐。 watchEffect(() => { for (const collider of store.colliders) { if (collider.followPathId == null) collider.followPathId = 0 if (!(collider.followDuration > 0)) collider.followDuration = 2 if (collider.followPathRotation == null) collider.followPathRotation = false if (!['跟随导出', '线性', '贝塞尔'].includes(collider.followCurveMode)) collider.followCurveMode = '跟随导出' if (!Array.isArray(collider.followCurve) || collider.followCurve.length < 2) collider.followCurve = [{ x: 0, y: 0 }, { x: 1, y: 1 }] } }) const collapsed = reactive>({ config: true, scene: false, emitMode: true, shape: true, attr: true, look: true, mods: true, export: false, }) function toggle(key: string) { collapsed[key] = !collapsed[key] } function setEmitMode(mode: 'stream' | 'burst') { const config = sys.value?.config if (!config) return config.mode = mode if (mode === 'stream' && (!(config.duration > 0))) config.duration = 1 } function setLoopDurationFrames(value: number) { const config = sys.value?.config if (!config) return const frames = Math.min(1800, Math.max(1, Math.ceil(Number(value) || 1))) config.loopDurationFrames = frames } function resetParticleFollow(config: any) { const defaults = defaultConfig() config.emitterFollow = false config.emitterFollowMode = 'none' config.emitterFollowPathId = defaults.emitterFollowPathId config.emitterFollowSpineId = defaults.emitterFollowSpineId config.emitterFollowBoneName = defaults.emitterFollowBoneName config.emitterFollowBoneOffsetX = defaults.emitterFollowBoneOffsetX config.emitterFollowBoneOffsetY = defaults.emitterFollowBoneOffsetY config.emitterFollowBoneRotation = defaults.emitterFollowBoneRotation config.emitterFollowBoneScaleX = defaults.emitterFollowBoneScaleX config.emitterFollowBoneScaleY = defaults.emitterFollowBoneScaleY config.emitterAngleMode = defaults.emitterAngleMode config.emitterFollowCurve = defaults.emitterFollowCurve.map((point) => ({ ...point })) config.emitterFollowDuration = defaults.emitterFollowDuration config.emitterFollowSpace = defaults.emitterFollowSpace config.emitterFollowDirection = defaults.emitterFollowDirection config.emitterFollowOffset = defaults.emitterFollowOffset } function setStreamBehavior(behavior: 'normal' | 'loop') { const config = sys.value?.config if (!config || config.streamBehavior === behavior) return const entering = behavior === 'loop' const message = entering ? '进入循环持续模式后,粒子跟随设置会失效并恢复默认值。是否继续?' : '退出循环持续模式后,粒子跟随设置会失效并恢复默认值。是否继续?' if (!window.confirm(message)) return resetParticleFollow(config) config.streamBehavior = behavior if (!entering) { config.generateLoopStartAnimation = false config.generateLoopEndAnimation = false } } function setLoopSection(section: 'start' | 'end', event: Event) { const config = sys.value?.config if (!config) return const checked = (event.target as HTMLInputElement).checked if (section === 'start') { config.generateLoopStartAnimation = checked } else { config.generateLoopEndAnimation = checked } } function setLoopSectionDuration(section: 'start' | 'end', event: Event) { const config = sys.value?.config if (!config) return const frames = Math.min(1800, Math.max(1, Math.ceil(Number((event.target as HTMLInputElement).value) || 30))) if (section === 'start') config.loopStartDurationFrames = frames else config.loopEndDurationFrames = frames } function setLoopCustomDuration(section: 'start' | 'end', event: Event) { const config = sys.value?.config if (!config) return const checked = (event.target as HTMLInputElement).checked if (section === 'start') config.loopStartUseCustomDuration = checked else config.loopEndUseCustomDuration = checked } function onEmitterFollowModeChange(event: Event, config: { emitterFollowMode: 'none' | 'path' | 'bone' emitterFollowSpineId: number emitterFollowBoneName: string }) { const mode = (event.target as HTMLSelectElement).value as 'none' | 'path' | 'bone' config.emitterFollowMode = mode if (mode !== 'bone') { config.emitterFollowSpineId = 0 config.emitterFollowBoneName = '' return } bindSelectedBone(config) } function bindSelectedBone(config: { emitterFollowMode: 'none' | 'path' | 'bone' emitterFollowSpineId: number emitterFollowBoneName: string }) { const target = selectedSpineBone.value if (!target) { config.emitterFollowMode = 'none' config.emitterFollowSpineId = 0 config.emitterFollowBoneName = '' return } config.emitterFollowMode = 'bone' config.emitterFollowSpineId = target.spine.id config.emitterFollowBoneName = target.boneName } type ResetModule = 'emitMode' | 'shape' | 'attr' | 'look' | 'mods' | 'export' const MODULE_RESET_KEYS: Record, string[]> = { emitMode: [ 'mode', 'rate', 'streamBehavior', 'loopDurationFrames', 'generateLoopStartAnimation', 'loopStartUseCustomDuration', 'loopStartDurationFrames', 'generateLoopEndAnimation', 'loopEndUseCustomDuration', 'loopEndDurationFrames', 'burstCount', 'burstLoop', 'delay', 'duration', ], shape: [ 'shape', 'showShape', 'shapeRangeVersion', 'innerRadius', 'outerRadius', 'innerRectW', 'innerRectH', 'outerRectW', 'outerRectH', 'spawnPathId', 'spawnPathDistribution', 'radius', 'rectW', 'rectH', 'emissionAngleMode', 'direction', 'spread', 'coneDirection', 'coneAngle', ], attr: [ 'lifeMin', 'lifeMax', 'lifeMode', 'lifeCurve', 'speedMin', 'speedMax', 'speedMode', 'speedCurve', 'scaleMin', 'scaleMax', 'scaleMode', 'scaleAxisMode', 'scaleYMin', 'scaleYMax', 'scaleCurve', 'scaleYCurve', 'scaleEndRatio', 'initialRotationMode', 'initialRotationMin', 'initialRotationMax', 'initialRotationCurve', 'rotationSpeedMin', 'rotationSpeedMax', 'speedOverLifeEnabled', 'speedOverLifeMode', 'speedOverLifeMin', 'speedOverLifeMax', 'speedOverLifeCurve', 'scaleOverLifeEnabled', 'scaleOverLifeMode', 'scaleOverLifeAxisMode', 'scaleOverLifeMin', 'scaleOverLifeMax', 'scaleYOverLifeMin', 'scaleYOverLifeMax', 'scaleOverLifeCurve', 'scaleYOverLifeCurve', 'rotationOverLifeEnabled', 'rotationOverLifeMode', 'rotationOverLifeCurve', ], look: [ 'texture', 'imageResources', 'imageWeightVersion', 'alpha', 'alphaEnd', 'colorStart', 'colorEnd', 'colorOverLife', 'blend', 'alphaMode', 'curveMode', 'alphaCurve', 'textureName', 'textureFolder', 'alphaScale', 'anchorX', 'anchorY', 'independentAlpha', ], mods: [ 'noise', 'noiseAmt', 'noiseRange', 'noiseSpeed', 'useGravity', 'gravity', 'gravityMode', 'gravityMin', 'gravityMax', 'gravityDirection', 'gravityCurve', 'wind', 'windX', 'windY', 'windMode', 'windMin', 'windMax', 'windDirection', 'windCurve', 'forceField', 'forceStrength', 'forceRadius', 'forceCenterX', 'forceCenterY', 'forceFalloff', 'showForceFieldShape', 'vortex', 'vortexStrength', 'vortexRadius', 'vortexCenterX', 'vortexCenterY', 'vortexFalloff', 'showVortexShape', 'attraction', 'attractionCenterX', 'attractionCenterY', 'attractionRadius', 'attractionDuration', 'attractionDelay', 'attractionDelayMode', 'attractionPath', 'showAttractionShape', 'trail', 'trailConfigVersion', 'trailResources', 'trailResourceVersion', 'trailTexture', 'trailTextureName', 'trailPreviewUrl', 'trailColorMode', 'trailColor', 'trailColorGradient', 'trailAlphaMode', 'trailAlpha', 'trailAlphaCurve', 'trailBoneCount', 'trailLength', 'trailWidth', 'trailWidthMode', 'trailLifeLengthEnabled', 'trailLifeLengthCurve', 'trailShapeEnabled', 'trailShapeCurve', 'trailGridRows', 'trailGridCols', 'collision', 'collisionColliderIds', 'pathFollow', 'emitterFollow', 'emitterFollowMode', 'emitterFollowPathId', 'emitterFollowSpineId', 'emitterFollowBoneName', 'emitterFollowBoneOffsetX', 'emitterFollowBoneOffsetY', 'emitterFollowBoneRotation', 'emitterFollowBoneScaleX', 'emitterFollowBoneScaleY', 'emitterAngleMode', 'emitterFollowCurve', 'emitterFollowDuration', 'emitterFollowSpace', 'emitterFollowDirection', 'emitterFollowOffset', 'damping', ], } function resetModule(module: ResetModule) { const config = sys.value?.config as any if (module === 'export') { store.settings.exportSettings = defaultSpineExportSettings() return } if (!config) return const defaults = defaultConfig() as any for (const key of MODULE_RESET_KEYS[module]) config[key] = defaults[key] // 重置资源模块后,Stage 会在下一帧重新挂载默认 star/trail 纹理。 } function normalizeExportFps() { exportSettings.value.fps = Math.min(240, Math.max(1, Math.round(Number(exportSettings.value.fps) || 30))) } function select(i: number) { store.selectSystem(store.systems[i]?.id ?? 0) } function addSys() { store.addSystem() } function addCollider() { store.addCollider() } function addPath() { store.addPath() } function addSpine() { store.addSpine() } function copySelectedObject() { store.duplicateSelectedObject() } function pathAnchor(x: number, y: number, inX = 0, inY = 0, outX = 0, outY = 0) { return { x, y, inX, inY, outX, outY } } function applyPathPreset() { const path = activePath.value if (!path || !path.preset) return if (path.preset === 'circle') { const radius = 110, handle = radius * 0.5522848 path.points = [ pathAnchor(radius, 0, 0, -handle, 0, handle), pathAnchor(0, radius, handle, 0, -handle, 0), pathAnchor(-radius, 0, 0, handle, 0, -handle), pathAnchor(0, -radius, -handle, 0, handle, 0), ] path.closed = true } else if (path.preset === 'square') { path.points = [pathAnchor(-100, -100), pathAnchor(100, -100), pathAnchor(100, 100), pathAnchor(-100, 100)] path.closed = true } else { const sides = path.preset === 'star' ? 10 : 6 path.points = Array.from({ length: sides }, (_, index) => { const radius = path.preset === 'star' && index % 2 ? 50 : 110 const angle = -Math.PI * 0.5 + index / sides * Math.PI * 2 return pathAnchor(Math.cos(angle) * radius, Math.sin(angle) * radius) }) path.closed = true } } function clearPathAnchors() { if (activePath.value) activePath.value.points = [] } function randomizeSeed() { if (sys.value) sys.value.config.seed = Math.floor(Math.random() * 999999) + 1 } function setSpeedOverLifeMode(mode: 'fixed' | 'random' | 'curve' | 'direction') { const config = sys.value?.config if (!config || mode === 'direction') return config.speedOverLifeMode = mode // 旧默认是 1~1,曲线映射后永远为 1;切换曲线时改为有效倍率范围。 if (mode === 'curve' && config.speedOverLifeMin === config.speedOverLifeMax) { config.speedOverLifeMin = 0 config.speedOverLifeMax = 1 } } function prepareScaleOverLifeCurve() { const config = sys.value?.config if (!config || config.scaleOverLifeMode !== 'curve') return if (config.scaleOverLifeMin === config.scaleOverLifeMax) { config.scaleOverLifeMin = 0 config.scaleOverLifeMax = 1 } if (config.scaleYOverLifeMin === config.scaleYOverLifeMax) { config.scaleYOverLifeMin = 0 config.scaleYOverLifeMax = 1 } } function setScaleOverLifeMode(mode: 'fixed' | 'random' | 'curve' | 'direction') { const config = sys.value?.config if (!config || mode === 'direction') return config.scaleOverLifeMode = mode prepareScaleOverLifeCurve() } // 各发射器形状的默认参数:切换形状时应用(只影响形状相关字段,不牵连其他) const SHAPE_DEFAULTS: Record> = { point: { emissionAngleMode: 'random', direction: 0, spread: 360 }, circle: { innerRadius: 0, outerRadius: 100, emissionAngleMode: 'fixed', direction: -90, spread: 0 }, rect: { innerRectW: 0, innerRectH: 0, outerRectW: 200, outerRectH: 100, emissionAngleMode: 'fixed', direction: -90, spread: 0 }, cone: { innerRadius: 0, outerRadius: 100, coneAngle: 90, coneDirection: 90, emissionAngleMode: 'random', direction: 90, spread: 90 }, path: { spawnPathId: 0, spawnPathDistribution: 'fixed', emissionAngleMode: 'fixed', direction: 0, spread: 0 }, } function onShapeChange() { const cfg = sys.value?.config if (!cfg) return const d = SHAPE_DEFAULTS[cfg.shape] if (!d) return Object.assign(cfg, d) } function onSpawnPathChange() { const cfg = sys.value?.config if (!cfg || cfg.spawnPathId > 0) return if (cfg.emissionAngleMode === 'path-normal' || cfg.emissionAngleMode === 'path-tangent') cfg.emissionAngleMode = 'fixed' } // 重置/删除图片 → 回到默认 star.png return { store, exportSettings, exportBusy, exportTarget, exportMessage, exportError, connectorPairUrl, connectorStatusText, connectorStatusClass, connectorReady, connectorAuthorizationUrl, connectorDisabledReason, connectorButtonTitle, refreshConnectorHealth, exportSpineJson, exportSpineProject, exportSpineImages, configFileInput, selectedPreset, presetOptions, presetLoading, configMessage, configError, saveConfig, onConfigFileChange, refreshPresets, loadSelectedPreset, resolvedCurveEditorMode, sys, textureFileInput, sequenceFileInput, trailTextureFileInput, addTrailResource, openTrailTexturePicker, onTrailRigChange, onTrailResourceBoneCountChange, onTrailResourceRotationChange, onTrailTextureFile, resetTrailTexture, removeTrailResource, setTrailResourceWeight, toggleTrailResourceLock, setTrailColor, commitTrailColor, addImageResource, openTexturePicker, openSequencePicker, onTextureFile, onSequenceFiles, resetTexture, setResourceFixedColor, commitResourceFixedColor, removeImageResource, setResourceWeight, toggleResourceLock, automaticLoopDuration, loopDurationFrames, activeObjectType, activeCollider, activePath, activeSpine, canCopyObject, selectedSpineBone, boundSpineBone, collisionPickerOpen, selectedCollisionBodies, availableCollisionBodies, addCollisionBody, removeCollisionBody, collapsed, toggle, setEmitMode, setLoopDurationFrames, setStreamBehavior, setLoopSection, setLoopSectionDuration, setLoopCustomDuration, onEmitterFollowModeChange, bindSelectedBone, resetModule, normalizeExportFps, select, addSys, addCollider, addPath, addSpine, copySelectedObject, applyPathPreset, clearPathAnchors, randomizeSeed, setSpeedOverLifeMode, prepareScaleOverLifeCurve, setScaleOverLifeMode, onShapeChange, onSpawnPathChange, LINEAR_CURVE, FLAT_CURVE, ALPHA_CURVE, RESOURCE_ALPHA_CURVE, TRAIL_ALPHA_CURVE, TRAIL_SHAPE_CURVE } }