460 lines
23 KiB
TypeScript
460 lines
23 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'
|
|
|
|
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<Record<string, boolean>>({
|
|
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<Exclude<ResetModule, 'export'>, 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<string, Partial<any>> = {
|
|
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 }
|
|
}
|