资源拆分

This commit is contained in:
tianmo committed 2026-09-11 10:00:38 +08:00
1 parent f810e712ec
commit 83890faaea
38 files changed
+4586 -4144

No files matched your search

+479
View File
@@ -0,0 +1,479 @@
import { Texture } from 'pixi.js'
import type { EmitMode, EmitShape, Blend, AttributeMode, RotationMode, ScaleAxisMode, CurvePoint, ColorGradientStop, ColliderShape, CollisionResponse, SceneCollider, ParticleImageResource, ParticleSequenceFrame, TrailImageResource, EmitterConfig, ParticleState, TrailBoneState, TrailState } from './particleTypes'
import { BLEND_MAP } from './particleConstants'
export function defaultConfig(): EmitterConfig {
return {
texture: null as unknown as Texture,
imageResources: [{
id: 1,
texture: null,
textureName: 'star',
textureFolder: '',
previewUrl: '/images/star.png',
imageMode: 'fixed',
sequencePlayback: 'loop-forward',
sequenceFps: 30,
sequenceFrames: [],
weight: 100,
locked: false,
scale: 1,
anchorX: 0.5,
anchorY: 0.5,
colorMode: 'fixed',
colorStart: '#ffffff',
colorEnd: '#ff4d4d',
colorGradient: [{ position: 0, color: '#ffffff' }, { position: 1, color: '#ff4d4d' }],
blend: 'normal',
independentAlpha: 'off',
alpha: 1,
alphaCurveMode: '跟随导出',
alphaCurve: [{ x: 0, y: 0 }, { x: 0.1, y: 1 }, { x: 0.9, y: 1 }, { x: 1, y: 0 }],
}],
imageWeightVersion: 1,
name: 'ParticleSystem1',
mode: 'stream',
rate: 20,
streamBehavior: 'normal',
loopDurationFrames: 30,
generateLoopStartAnimation: false,
loopStartUseCustomDuration: false,
loopStartDurationFrames: 30,
generateLoopEndAnimation: false,
loopEndUseCustomDuration: false,
loopEndDurationFrames: 30,
burstCount: 50,
burstLoop: false, // 一次性爆发(持续爆发开关已移除)
delay: 0,
duration: 1, // 持续发射时长;默认 1 秒(30f)。爆发模式运行时按 0 处理。
shape: 'point',
shapeRangeVersion: 1,
innerRadius: 0,
outerRadius: 30,
innerRectW: 0,
innerRectH: 0,
outerRectW: 60,
outerRectH: 40,
spawnPathId: 0,
spawnPathDistribution: 'fixed',
radius: 30,
rectW: 60,
rectH: 40,
centerX: 0,
centerY: 0,
rootRotation: 0,
rootScaleX: 1,
rootScaleY: 1,
emissionAngleMode: 'random',
direction: 0,
spread: 360,
coneDirection: 90,
coneAngle: 60,
lifeMin: 0.6,
lifeMax: 1.8,
lifeMode: 'random',
lifeCurve: [{ x: 0, y: 0 }, { x: 1, y: 1 }],
speedMin: 30,
speedMax: 140,
speedMode: 'random',
speedCurve: [{ x: 0, y: 0 }, { x: 1, y: 1 }],
scaleMin: 0.3,
scaleMax: 0.8,
scaleMode: 'random',
scaleAxisMode: 'uniform',
scaleYMin: 0.3,
scaleYMax: 0.8,
scaleCurve: [{ x: 0, y: 0 }, { x: 1, y: 1 }],
scaleYCurve: [{ x: 0, y: 0 }, { x: 1, y: 1 }],
scaleEndRatio: 0.3,
initialRotationMode: 'random',
initialRotationMin: 0,
initialRotationMax: 360,
initialRotationCurve: [{ x: 0, y: 0 }, { x: 1, y: 1 }],
alpha: 0.9,
alphaEnd: 0,
rotationSpeedMin: -180,
rotationSpeedMax: 180,
speedOverLifeEnabled: false,
speedOverLifeMode: 'fixed',
speedOverLifeMin: 1,
speedOverLifeMax: 1,
speedOverLifeCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
scaleOverLifeEnabled: false,
scaleOverLifeMode: 'curve',
scaleOverLifeAxisMode: 'uniform',
scaleOverLifeMin: 0,
scaleOverLifeMax: 1,
scaleYOverLifeMin: 0,
scaleYOverLifeMax: 1,
scaleOverLifeCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
scaleYOverLifeCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
rotationOverLifeEnabled: false,
rotationOverLifeMode: 'random',
rotationOverLifeCurve: [{ x: 0, y: 0 }, { x: 1, y: 1 }],
gravity: 980,
gravityMode: 'fixed',
gravityMin: 980,
gravityMax: 980,
gravityDirection: -90,
gravityCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
damping: 0,
seed: 0,
colorStart: '#ffffff',
colorEnd: '#ff4d4d',
colorOverLife: false,
blend: 'add',
alphaMode: 'curve',
curveMode: '线性',
alphaCurve: [{ x: 0, y: 0 }, { x: 0.2, y: 1 }, { x: 0.8, y: 1 }, { x: 1, y: 0 }],
textureName: 'star',
textureFolder: '',
alphaScale: 1,
anchorX: 0.5,
anchorY: 0.5,
independentAlpha: 'off',
offsetX: 0,
offsetY: 0,
noise: false,
noiseAmt: 30,
noiseRange: 100,
noiseSpeed: 1,
useGravity: false,
wind: false,
windX: 0,
windY: 0,
windMode: 'fixed',
windMin: 100,
windMax: 100,
windDirection: 0,
windCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
forceField: false,
forceStrength: 10,
forceRadius: 100,
forceCenterX: 0,
forceCenterY: 0,
forceFalloff: 'linear',
attraction: false,
attractionCenterX: 200,
attractionCenterY: 0,
attractionRadius: 20,
attractionDuration: 0.5,
attractionDelay: 0.1,
attractionDelayMode: 'particle',
attractionPath: 'curve',
trail: false,
trailConfigVersion: 3,
trailResources: [{
id: 1,
texture: null,
textureName: 'trail',
previewUrl: '/images/trail.png',
weight: 100,
locked: false,
rotation: 0,
colorMode: 'particle',
color: '#ffffff',
colorGradient: [{ position: 0, color: '#ffffff' }, { position: 1, color: '#ffffff' }],
blend: 'normal',
alphaMode: 'particle',
alpha: 1,
alphaCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
boneCount: 3,
trailRatio: 100,
lengthMode: 'fixed',
length: 100,
lengthMin: 100,
lengthMax: 100,
widthSizeMode: 'fixed',
width: 100,
widthMin: 100,
widthMax: 100,
widthMode: 'particle',
lifeLengthEnabled: false,
lifeLengthCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
shapeEnabled: false,
shapeCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
}],
trailResourceVersion: 6,
trailTexture: null,
trailTextureName: 'trail',
trailPreviewUrl: '/images/trail.png',
trailColorMode: 'particle',
trailColor: '#ffffff',
trailColorGradient: [{ position: 0, color: '#ffffff' }, { position: 1, color: '#ffffff' }],
trailAlphaMode: 'particle',
trailAlpha: 1,
trailAlphaCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
trailBoneCount: 3,
trailLength: 100,
trailWidth: 100,
trailWidthMode: 'particle',
trailLifeLengthEnabled: false,
trailLifeLengthCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
trailShapeEnabled: false,
trailShapeCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
trailGridRows: 4,
trailGridCols: 4,
collision: false,
collisionColliderIds: [],
pathFollow: false,
emitterFollow: false,
emitterFollowMode: 'none',
emitterFollowPathId: 0,
emitterFollowSpineId: 0,
emitterFollowBoneName: '',
emitterFollowBoneOffsetX: 0,
emitterFollowBoneOffsetY: 0,
emitterFollowBoneRotation: 0,
emitterFollowBoneScaleX: 1,
emitterFollowBoneScaleY: 1,
emitterAngleMode: 'fixed',
emitterFollowCurve: [{ x: 0, y: 0 }, { x: 1, y: 1 }],
emitterFollowDuration: 2,
emitterFollowSpace: 'world',
emitterFollowDirection: 'forward',
emitterFollowOffset: 0,
}
}
/** 为热更新或旧版配置补齐后来新增的字段,避免 undefined 进入粒子计算。 */
export function ensureEmitterConfig(config: EmitterConfig): EmitterConfig {
const defaults = defaultConfig() as unknown as Record<string, unknown>
const target = config as unknown as Record<string, unknown>
const needsShapeRangeMigration = target.shapeRangeVersion !== 1
const legacyRadius = Number(target.radius)
const legacyRectW = Number(target.rectW)
const legacyRectH = Number(target.rectH)
const legacyDirection = Number(target.direction)
const legacySpread = Number(target.spread)
const legacyShape = target.shape
const needsImageWeightMigration = target.imageWeightVersion !== 1
const needsLoopDurationMigration = target.loopDurationFrames == null
const needsGravityMigration = target.gravityMode == null
const legacyGravity = Number(target.gravity)
const needsWindMigration = target.windMode == null
const legacyWindX = Number(target.windX)
const legacyWindY = Number(target.windY)
const needsForceFieldMigration = target.forceFalloff == null
const legacyForceStrength = Number(target.forceStrength)
const needsTrailCurveMigration = target.trailConfigVersion !== 3
const needsTrailResourceMigration = !Array.isArray(target.trailResources)
const needsTrailResourceStyleMigration = target.trailResourceVersion !== 6
const legacyTrailTexture = target.trailTexture as Texture<any> | null | undefined
const legacyTrailTextureName = String(target.trailTextureName || 'trail')
const legacyTrailPreviewUrl = String(target.trailPreviewUrl || '/images/trail.png').replace('/trails/trail.png', '/images/trail.png')
for (const [key, value] of Object.entries(defaults)) {
if (target[key] !== undefined && target[key] !== null) continue
target[key] = Array.isArray(value)
? value.map((item) => typeof item === 'object' && item !== null ? { ...item } : item)
: value
}
if (needsShapeRangeMigration) {
config.innerRadius = 0
config.outerRadius = Math.max(0, Number.isFinite(legacyRadius) ? legacyRadius : defaults.outerRadius as number)
config.innerRectW = 0
config.innerRectH = 0
config.outerRectW = Math.max(0, Number.isFinite(legacyRectW) ? legacyRectW : defaults.outerRectW as number)
config.outerRectH = Math.max(0, Number.isFinite(legacyRectH) ? legacyRectH : defaults.outerRectH as number)
config.coneDirection = Number.isFinite(legacyDirection) ? legacyDirection : 90
config.emissionAngleMode = Number.isFinite(legacySpread) && legacySpread > 0 ? 'random' : 'fixed'
if (legacyShape === 'cone') config.spread = Math.max(0, Number(config.coneAngle) || 0)
config.shapeRangeVersion = 1
}
if (config.shape === 'orbit') config.shape = 'path'
if (!['fixed', 'random', 'radial', 'path-normal', 'path-tangent'].includes(config.emissionAngleMode)) config.emissionAngleMode = 'fixed'
if (!['circle', 'rect', 'cone'].includes(config.shape) && config.emissionAngleMode === 'radial') config.emissionAngleMode = 'fixed'
if (config.shape !== 'path' && (config.emissionAngleMode === 'path-normal' || config.emissionAngleMode === 'path-tangent')) config.emissionAngleMode = 'fixed'
config.spawnPathId = Math.max(0, Math.round(Number(config.spawnPathId) || 0))
if (config.spawnPathDistribution !== 'fixed' && config.spawnPathDistribution !== 'random') config.spawnPathDistribution = 'fixed'
config.innerRadius = Math.max(0, Number(config.innerRadius) || 0)
config.outerRadius = Math.max(config.innerRadius, Number(config.outerRadius) || 0)
config.innerRectW = Math.max(0, Number(config.innerRectW) || 0)
config.innerRectH = Math.max(0, Number(config.innerRectH) || 0)
config.outerRectW = Math.max(config.innerRectW, Number(config.outerRectW) || 0)
config.outerRectH = Math.max(config.innerRectH, Number(config.outerRectH) || 0)
const normalizedCollisionColliderIds = Array.from(new Set(
(Array.isArray(config.collisionColliderIds) ? config.collisionColliderIds : [])
.map((id) => Math.round(Number(id)))
.filter((id) => Number.isFinite(id) && id > 0),
))
if (normalizedCollisionColliderIds.length !== config.collisionColliderIds.length
|| normalizedCollisionColliderIds.some((id, index) => id !== config.collisionColliderIds[index])) {
config.collisionColliderIds = normalizedCollisionColliderIds
}
if (config.streamBehavior !== 'normal' && config.streamBehavior !== 'loop') config.streamBehavior = 'normal'
if (needsLoopDurationMigration || !Number.isFinite(config.loopDurationFrames)) config.loopDurationFrames = Math.ceil(Math.max(1 / 30, Number(config.duration) || 1) * 30)
config.loopDurationFrames = Math.min(1800, Math.max(1, Math.ceil(config.loopDurationFrames)))
if (typeof config.generateLoopStartAnimation !== 'boolean') config.generateLoopStartAnimation = false
if (typeof config.generateLoopEndAnimation !== 'boolean') config.generateLoopEndAnimation = false
if (typeof config.loopStartUseCustomDuration !== 'boolean') config.loopStartUseCustomDuration = false
if (typeof config.loopEndUseCustomDuration !== 'boolean') config.loopEndUseCustomDuration = false
config.loopStartDurationFrames = Math.min(1800, Math.max(1, Math.ceil(Number(config.loopStartDurationFrames) || 30)))
config.loopEndDurationFrames = Math.min(1800, Math.max(1, Math.ceil(Number(config.loopEndDurationFrames) || 30)))
if (!Array.isArray(config.imageResources) || config.imageResources.length === 0) {
config.imageResources = defaultConfig().imageResources
}
for (const resource of config.imageResources) {
if (!resource.texture && config.texture) resource.texture = config.texture
if (!resource.textureName) resource.textureName = 'star'
if (resource.previewUrl === '/particles/star.png') resource.previewUrl = '/images/star.png'
if (!resource.previewUrl) resource.previewUrl = resource.textureName === 'star' ? '/images/star.png' : ''
if (resource.imageMode !== 'fixed' && resource.imageMode !== 'sequence') resource.imageMode = 'fixed'
if ((resource.sequencePlayback as string) === 'once') resource.sequencePlayback = 'once-hold'
else if ((resource.sequencePlayback as string) === 'loop') resource.sequencePlayback = 'loop-forward'
else if (!['once-hide', 'once-hold', 'loop-forward', 'loop-reverse'].includes(resource.sequencePlayback)) {
resource.sequencePlayback = 'loop-forward'
}
if (!Number.isFinite(resource.sequenceFps)) resource.sequenceFps = 30
resource.sequenceFps = Math.min(120, Math.max(1, Number(resource.sequenceFps) || 30))
if (!Array.isArray(resource.sequenceFrames)) resource.sequenceFrames = []
for (const frame of resource.sequenceFrames) {
if (typeof frame.name !== 'string') frame.name = ''
if (typeof frame.previewUrl !== 'string') frame.previewUrl = ''
if (frame.texture === undefined) frame.texture = null
}
if (!Number.isFinite(resource.weight)) resource.weight = 100
if (typeof resource.locked !== 'boolean') resource.locked = false
if (!Number.isFinite(resource.scale)) resource.scale = 1
if (!Number.isFinite(resource.anchorX)) resource.anchorX = 0.5
if (!Number.isFinite(resource.anchorY)) resource.anchorY = 0.5
if (resource.colorMode !== 'fixed' && resource.colorMode !== 'random' && resource.colorMode !== 'lifetime') {
resource.colorMode = config.colorOverLife ? 'lifetime' : 'fixed'
}
if (!resource.colorStart) resource.colorStart = config.colorStart || '#ffffff'
if (!resource.colorEnd) resource.colorEnd = config.colorEnd || resource.colorStart
if (!Array.isArray(resource.colorGradient) || resource.colorGradient.length < 2) {
resource.colorGradient = [
{ position: 0, color: resource.colorStart },
{ position: 1, color: resource.colorEnd },
]
}
if (!Object.prototype.hasOwnProperty.call(BLEND_MAP, resource.blend)) resource.blend = config.blend || 'normal'
if (!['off', 'fixed', 'curve'].includes(resource.independentAlpha)) resource.independentAlpha = config.independentAlpha || 'off'
if (!Number.isFinite(resource.alpha)) resource.alpha = Number.isFinite(config.alphaScale) ? config.alphaScale : 1
if (!['跟随导出', '线性', '贝塞尔'].includes(resource.alphaCurveMode)) resource.alphaCurveMode = config.curveMode || '跟随导出'
if (!Array.isArray(resource.alphaCurve) || resource.alphaCurve.length < 2) {
resource.alphaCurve = (config.alphaCurve || [{ x: 0, y: 1 }, { x: 1, y: 1 }]).map((point) => ({ ...point }))
}
}
if (config.imageResources.length === 1) {
config.imageResources[0].weight = 100
config.imageResources[0].locked = false
} else if (needsImageWeightMigration) {
const equalWeight = 100 / config.imageResources.length
config.imageResources.forEach((resource, index) => {
resource.weight = index === config.imageResources.length - 1
? 100 - equalWeight * (config.imageResources.length - 1)
: equalWeight
resource.locked = false
})
}
config.imageWeightVersion = 1
if (needsGravityMigration) {
const migratedStrength = Number.isFinite(legacyGravity) && legacyGravity !== 0 ? legacyGravity : 980
config.gravity = migratedStrength
config.gravityMin = migratedStrength
config.gravityMax = migratedStrength
}
if (needsWindMigration) {
const hasLegacyWind = Number.isFinite(legacyWindX) && Number.isFinite(legacyWindY) && (legacyWindX !== 0 || legacyWindY !== 0)
config.windMin = hasLegacyWind ? Math.hypot(legacyWindX, legacyWindY) : 100
config.windMax = config.windMin
config.windDirection = hasLegacyWind ? Math.atan2(-legacyWindY, legacyWindX) * 180 / Math.PI : 0
}
if (needsForceFieldMigration) {
config.forceStrength = Number.isFinite(legacyForceStrength) && legacyForceStrength !== 0 ? legacyForceStrength : 10
}
if (needsTrailCurveMigration) {
config.trailConfigVersion = 3
config.trailAlphaCurve = [{ x: 0, y: 1 }, { x: 1, y: 1 }]
config.trailLifeLengthCurve = [{ x: 0, y: 1 }, { x: 1, y: 1 }]
config.trailShapeCurve = [{ x: 0, y: 1 }, { x: 1, y: 1 }]
config.trailGridRows = 4
config.trailGridCols = 4
}
if (needsTrailResourceMigration || !config.trailResources.length) {
config.trailResources = [{
id: 1,
texture: legacyTrailTexture || null,
textureName: legacyTrailTextureName,
previewUrl: legacyTrailPreviewUrl,
weight: 100,
locked: false,
rotation: 0,
colorMode: config.trailColorMode,
color: config.trailColor,
colorGradient: config.trailColorGradient.map((stop) => ({ ...stop })),
blend: 'normal',
alphaMode: config.trailAlphaMode,
alpha: config.trailAlpha,
alphaCurve: config.trailAlphaCurve.map((point) => ({ ...point })),
boneCount: config.trailBoneCount,
trailRatio: 100,
lengthMode: 'fixed',
length: config.trailLength,
lengthMin: config.trailLength,
lengthMax: config.trailLength,
widthSizeMode: 'fixed',
width: config.trailWidth,
widthMin: config.trailWidth,
widthMax: config.trailWidth,
widthMode: config.trailWidthMode,
lifeLengthEnabled: config.trailLifeLengthEnabled,
lifeLengthCurve: config.trailLifeLengthCurve.map((point) => ({ ...point })),
shapeEnabled: config.trailShapeEnabled,
shapeCurve: config.trailShapeCurve.map((point) => ({ ...point })),
}]
}
for (const resource of config.trailResources) {
if (!resource.textureName) resource.textureName = 'trail'
if (resource.previewUrl === '/trails/trail.png') resource.previewUrl = '/images/trail.png'
if (!resource.previewUrl) resource.previewUrl = '/images/trail.png'
if (!Number.isFinite(resource.weight)) resource.weight = 100
if (typeof resource.locked !== 'boolean') resource.locked = false
if (!Number.isFinite(resource.rotation)) resource.rotation = 0
if (!['particle', 'fixed', 'curve'].includes(resource.colorMode)) resource.colorMode = config.trailColorMode || 'particle'
if (!resource.color) resource.color = config.trailColor || '#ffffff'
if (!Array.isArray(resource.colorGradient) || resource.colorGradient.length < 2) {
resource.colorGradient = (config.trailColorGradient || [{ position: 0, color: '#ffffff' }, { position: 1, color: '#ffffff' }]).map((stop) => ({ ...stop }))
}
if (!Object.prototype.hasOwnProperty.call(BLEND_MAP, resource.blend)) resource.blend = 'normal'
if (!['particle', 'fixed', 'curve'].includes(resource.alphaMode)) resource.alphaMode = config.trailAlphaMode || 'particle'
if (!Number.isFinite(resource.alpha)) resource.alpha = Number.isFinite(config.trailAlpha) ? config.trailAlpha : 1
if (!Array.isArray(resource.alphaCurve) || resource.alphaCurve.length < 2) {
resource.alphaCurve = (config.trailAlphaCurve || [{ x: 0, y: 1 }, { x: 1, y: 1 }]).map((point) => ({ ...point }))
}
if (!Number.isFinite(resource.boneCount)) resource.boneCount = Number.isFinite(config.trailBoneCount) ? config.trailBoneCount : 3
if (!Number.isFinite(resource.trailRatio)) resource.trailRatio = 100
resource.trailRatio = Math.min(100, Math.max(0, resource.trailRatio))
if (!['fixed', 'random'].includes(resource.lengthMode)) resource.lengthMode = 'fixed'
if (!Number.isFinite(resource.length)) resource.length = Number.isFinite(config.trailLength) ? config.trailLength : 100
if (!Number.isFinite(resource.lengthMin)) resource.lengthMin = resource.length
if (!Number.isFinite(resource.lengthMax)) resource.lengthMax = resource.length
if (!['fixed', 'random'].includes(resource.widthSizeMode)) resource.widthSizeMode = 'fixed'
if (!Number.isFinite(resource.width)) resource.width = Number.isFinite(config.trailWidth) ? config.trailWidth : 100
if (!Number.isFinite(resource.widthMin)) resource.widthMin = resource.width
if (!Number.isFinite(resource.widthMax)) resource.widthMax = resource.width
if (!['particle', 'fixed'].includes(resource.widthMode)) resource.widthMode = config.trailWidthMode || 'particle'
if (typeof resource.lifeLengthEnabled !== 'boolean') resource.lifeLengthEnabled = Boolean(config.trailLifeLengthEnabled)
if (!Array.isArray(resource.lifeLengthCurve) || resource.lifeLengthCurve.length < 2) {
resource.lifeLengthCurve = (config.trailLifeLengthCurve || [{ x: 0, y: 1 }, { x: 1, y: 1 }]).map((point) => ({ ...point }))
}
if (typeof resource.shapeEnabled !== 'boolean') resource.shapeEnabled = Boolean(config.trailShapeEnabled)
if (!Array.isArray(resource.shapeCurve) || resource.shapeCurve.length < 2) {
resource.shapeCurve = (config.trailShapeCurve || [{ x: 0, y: 1 }, { x: 1, y: 1 }]).map((point) => ({ ...point }))
}
}
if (config.trailResources.length === 1) {
config.trailResources[0].weight = 100
config.trailResources[0].locked = false
}
if (needsTrailResourceStyleMigration) {
for (const resource of config.trailResources) {
resource.alphaCurve = resource.alphaCurve?.length >= 2 ? resource.alphaCurve : [{ x: 0, y: 1 }, { x: 1, y: 1 }]
}
}
config.trailResourceVersion = 6
return config
}
+4
View File
@@ -0,0 +1,4 @@
import type { Blend } from './particleTypes'
export const BLEND_MAP: Record<Blend, number> = { normal: 0, add: 1, multiply: 2, screen: 3 }
export const MAX_PARTICLES = 400
File diff suppressed because it is too large. Load diff
+225
View File
@@ -0,0 +1,225 @@
import type { EmitMode, EmitShape, Blend, AttributeMode, RotationMode, ScaleAxisMode, CurvePoint, ColorGradientStop, ColliderShape, CollisionResponse, SceneCollider, ParticleImageResource, ParticleSequenceFrame, TrailImageResource, EmitterConfig, ParticleState, TrailBoneState, TrailState } from './particleTypes'
export function lerp(a: number, b: number, t: number) { return a + (b - a) * t }
export function finite(value: number, fallback: number) { return Number.isFinite(value) ? value : fallback }
export type SimulationTransform = { centerX: number; centerY: number; rotation: number; scaleX?: number; scaleY?: number } | null
export function localToWorldMath(cfg: EmitterConfig, localX: number, localY: number, transform: SimulationTransform = null) {
const scaleX = Math.max(0.0001, Math.abs(transform?.scaleX ?? cfg.rootScaleX))
const scaleY = Math.max(0.0001, Math.abs(transform?.scaleY ?? cfg.rootScaleY))
const angle = -(transform?.rotation ?? cfg.rootRotation) * Math.PI / 180
const cos = Math.cos(angle), sin = Math.sin(angle)
const centerX = transform?.centerX ?? cfg.centerX
const centerY = transform?.centerY ?? cfg.centerY
const screenX = centerX + cos * scaleX * localX - sin * scaleY * localY
const screenY = -centerY + sin * scaleX * localX + cos * scaleY * localY
return { x: screenX, y: -screenY }
}
export function worldMathToLocal(cfg: EmitterConfig, worldX: number, worldY: number, transform: SimulationTransform = null) {
const scaleX = Math.max(0.0001, Math.abs(transform?.scaleX ?? cfg.rootScaleX))
const scaleY = Math.max(0.0001, Math.abs(transform?.scaleY ?? cfg.rootScaleY))
const angle = -(transform?.rotation ?? cfg.rootRotation) * Math.PI / 180
const cos = Math.cos(angle), sin = Math.sin(angle)
const screenX = worldX - (transform?.centerX ?? cfg.centerX)
const screenY = -worldY + (transform?.centerY ?? cfg.centerY)
return {
x: (cos * screenX + sin * screenY) / scaleX,
y: (-sin * screenX + cos * screenY) / scaleY,
}
}
export function localVelocityToWorldMath(cfg: EmitterConfig, vx: number, vy: number, transform: SimulationTransform = null) {
const scaleX = Math.max(0.0001, Math.abs(transform?.scaleX ?? cfg.rootScaleX))
const scaleY = Math.max(0.0001, Math.abs(transform?.scaleY ?? cfg.rootScaleY))
const angle = -(transform?.rotation ?? cfg.rootRotation) * Math.PI / 180
const cos = Math.cos(angle), sin = Math.sin(angle)
const screenX = cos * scaleX * vx - sin * scaleY * vy
const screenY = sin * scaleX * vx + cos * scaleY * vy
return { x: screenX, y: -screenY }
}
export function worldVelocityToLocalMath(cfg: EmitterConfig, vx: number, vy: number, transform: SimulationTransform = null) {
const scaleX = Math.max(0.0001, Math.abs(transform?.scaleX ?? cfg.rootScaleX))
const scaleY = Math.max(0.0001, Math.abs(transform?.scaleY ?? cfg.rootScaleY))
const angle = -(transform?.rotation ?? cfg.rootRotation) * Math.PI / 180
const cos = Math.cos(angle), sin = Math.sin(angle)
const screenY = -vy
return {
x: (cos * vx + sin * screenY) / scaleX,
y: (-sin * vx + cos * screenY) / scaleY,
}
}
export type ColliderHit = { x: number; y: number; nx: number; ny: number }
export function resolveColliderPoint(collider: SceneCollider, worldX: number, worldY: number): ColliderHit | null {
const angle = collider.rotation * Math.PI / 180
const cos = Math.cos(angle), sin = Math.sin(angle)
const scaleX = Math.max(0.0001, Math.abs(collider.scaleX ?? 1))
const scaleY = Math.max(0.0001, Math.abs(collider.scaleY ?? 1))
const dx = worldX - collider.x, dy = worldY - collider.y
const localX = (cos * dx + sin * dy) / scaleX
const localY = (-sin * dx + cos * dy) / scaleY
const toWorld = (x: number, y: number, nx: number, ny: number): ColliderHit => {
// 非等比缩放下,法线使用逆转置缩放后再旋转,保证反弹方向正确。
const scaledNormalX = nx / scaleX
const scaledNormalY = ny / scaleY
const normalLength = Math.max(0.0001, Math.hypot(scaledNormalX, scaledNormalY))
const localNormalX = scaledNormalX / normalLength
const localNormalY = scaledNormalY / normalLength
const worldNormalX = cos * localNormalX - sin * localNormalY
const worldNormalY = sin * localNormalX + cos * localNormalY
return {
x: collider.x + cos * x * scaleX - sin * y * scaleY + worldNormalX * 0.05,
y: collider.y + sin * x * scaleX + cos * y * scaleY + worldNormalY * 0.05,
nx: worldNormalX,
ny: worldNormalY,
}
}
if (collider.shape === 'circle') {
const radius = Math.max(0.001, collider.radius)
const distance = Math.hypot(localX, localY)
if (distance >= radius) return null
const nx = distance > 0.0001 ? localX / distance : 1
const ny = distance > 0.0001 ? localY / distance : 0
return toWorld(nx * radius, ny * radius, nx, ny)
}
if (collider.shape === 'ellipse') {
const radiusX = Math.max(0.001, collider.radiusX)
const radiusY = Math.max(0.001, collider.radiusY)
const normalized = Math.hypot(localX / radiusX, localY / radiusY)
if (normalized >= 1) return null
const scale = normalized > 0.0001 ? 1 / normalized : 1
const x = normalized > 0.0001 ? localX * scale : radiusX
const y = normalized > 0.0001 ? localY * scale : 0
const gradientLength = Math.max(0.0001, Math.hypot(x / (radiusX * radiusX), y / (radiusY * radiusY)))
return toWorld(x, y, x / (radiusX * radiusX) / gradientLength, y / (radiusY * radiusY) / gradientLength)
}
if (collider.shape === 'rect') {
const halfWidth = Math.max(0.001, collider.width * 0.5)
const halfHeight = Math.max(0.001, collider.height * 0.5)
if (Math.abs(localX) >= halfWidth || Math.abs(localY) >= halfHeight) return null
const penetrationX = halfWidth - Math.abs(localX)
const penetrationY = halfHeight - Math.abs(localY)
if (penetrationX < penetrationY) {
const nx = localX >= 0 ? 1 : -1
return toWorld(nx * halfWidth, localY, nx, 0)
}
const ny = localY >= 0 ? 1 : -1
return toWorld(localX, ny * halfHeight, 0, ny)
}
const sides = Math.max(3, Math.min(16, Math.round(collider.sides || (collider.shape === 'custom' ? 5 : 6))))
const radiusX = Math.max(0.001, collider.width * 0.5)
const radiusY = Math.max(0.001, collider.height * 0.5)
const points = Array.from({ length: sides }, (_, index) => {
const theta = Math.PI * 0.5 + index / sides * Math.PI * 2
return { x: Math.cos(theta) * radiusX, y: Math.sin(theta) * radiusY }
})
let inside = false
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
const a = points[i], b = points[j]
if ((a.y > localY) !== (b.y > localY) && localX < (b.x - a.x) * (localY - a.y) / (b.y - a.y) + a.x) inside = !inside
}
if (!inside) return null
let nearestX = points[0].x, nearestY = points[0].y, nearestDistance = Infinity
for (let i = 0; i < points.length; i++) {
const a = points[i], b = points[(i + 1) % points.length]
const edgeX = b.x - a.x, edgeY = b.y - a.y
const edgeLengthSq = Math.max(0.0001, edgeX * edgeX + edgeY * edgeY)
const amount = Math.min(1, Math.max(0, ((localX - a.x) * edgeX + (localY - a.y) * edgeY) / edgeLengthSq))
const x = a.x + edgeX * amount, y = a.y + edgeY * amount
const distance = Math.hypot(x - localX, y - localY)
if (distance < nearestDistance) { nearestDistance = distance; nearestX = x; nearestY = y }
}
const normalLength = Math.max(0.0001, Math.hypot(nearestX - localX, nearestY - localY))
return toWorld(nearestX, nearestY, (nearestX - localX) / normalLength, (nearestY - localY) / normalLength)
}
export function tFromLife(particle: { life: number; maxLife: number }) {
return Math.min(1, Math.max(0, 1 - particle.life / Math.max(0.0001, particle.maxLife)))
}
export function seededUnit(seed: number, index: number, salt: number) {
let value = ((seed >>> 0) ^ Math.imul(index + 1, 0x9e3779b1) ^ salt) >>> 0
value ^= value >>> 16
value = Math.imul(value, 0x7feb352d)
value ^= value >>> 15
value = Math.imul(value, 0x846ca68b)
value ^= value >>> 16
return (value >>> 0) / 4294967296
}
export function initialValue(mode: AttributeMode, min: number, max: number, curve: CurvePoint[], random: number) {
if (mode === 'fixed') return min
if (mode === 'curve') return lerp(min, max, curveAt(curve, random))
return lerp(min, max, random)
}
export function overLifeValue(mode: AttributeMode, min: number, max: number, curve: CurvePoint[], age: number, sampled: number) {
if (mode === 'curve') return lerp(min, max, curveAt(curve, age))
return mode === 'random' ? sampled : min
}
/** 在透明度曲线关键帧上按 x(生命周期时刻 0~1)求 y(透明度 0~1)。保证有序、端点外取端点 */
export function cubicValue(a: number, b: number, c: number, d: number, t: number) {
const inverse = 1 - t
return inverse * inverse * inverse * a + 3 * inverse * inverse * t * b + 3 * inverse * t * t * c + t * t * t * d
}
export function curveAt(pts: CurvePoint[] | undefined, x: number, mode: '线性' | '贝塞尔' = '线性'): number {
if (!Array.isArray(pts) || pts.length === 0) return 1
const p = [...pts].sort((a, b) => a.x - b.x)
if (x <= p[0].x) return p[0].y
if (x >= p[p.length - 1].x) return p[p.length - 1].y
for (let i = 0; i < p.length - 1; i++) {
if (x >= p[i].x && x <= p[i + 1].x) {
const start = p[i], end = p[i + 1]
const spanX = end.x - start.x || 1
if (mode !== '贝塞尔') return lerp(start.y, end.y, (x - start.x) / spanX)
const control1X = start.x + (start.outX ?? spanX / 3)
const control1Y = start.y + (start.outY ?? (end.y - start.y) / 3)
const control2X = end.x + (end.inX ?? -spanX / 3)
const control2Y = end.y + (end.inY ?? -(end.y - start.y) / 3)
let low = 0, high = 1
for (let iteration = 0; iteration < 14; iteration++) {
const amount = (low + high) / 2
if (cubicValue(start.x, control1X, control2X, end.x, amount) < x) low = amount
else high = amount
}
return cubicValue(start.y, control1Y, control2Y, end.y, (low + high) / 2)
}
}
return p[p.length - 1].y
}
/** 确定性伪随机(mulberry32),种子相同则序列相同 */
export function mulberry32(seed: number): () => number {
let a = seed >>> 0
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0
let t = Math.imul(a ^ (a >>> 15), 1 | a)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
export function hexToNumber(hex: string): number {
let h = hex.replace('#', '')
if (h.length === 3) h = h.split('').map((c) => c + c).join('')
return parseInt(h, 16)
}
export function blendRgb(start: string, end: string, t: number): number {
const s = hexToRgb(start), e = hexToRgb(end)
const r = Math.round(s[0] + (e[0] - s[0]) * t)
const g = Math.round(s[1] + (e[1] - s[1]) * t)
const b = Math.round(s[2] + (e[2] - s[2]) * t)
return (r << 16) | (g << 8) | b
}
export function gradientColorAt(stops: ColorGradientStop[] | undefined, t: number): number {
if (!Array.isArray(stops) || stops.length === 0) return 0xffffff
const sorted = [...stops].sort((a, b) => a.position - b.position)
if (t <= sorted[0].position) return hexToNumber(sorted[0].color)
if (t >= sorted[sorted.length - 1].position) return hexToNumber(sorted[sorted.length - 1].color)
for (let index = 0; index < sorted.length - 1; index++) {
const start = sorted[index], end = sorted[index + 1]
if (t < start.position || t > end.position) continue
const amount = (t - start.position) / Math.max(0.0001, end.position - start.position)
return blendRgb(start.color, end.color, amount)
}
return hexToNumber(sorted[sorted.length - 1].color)
}
export function hexToRgb(hex: string): [number, number, number] {
let h = hex.replace('#', '')
if (h.length === 3) h = h.split('').map((c) => c + c).join('')
const n = parseInt(h, 16)
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
}
+399
View File
@@ -0,0 +1,399 @@
import type { Texture } from 'pixi.js'
import type { EmissionAngleMode } from './emitterShape'
/** 发射模式 */
export type EmitMode = 'stream' | 'burst'
/** 发射形状 */
export type EmitShape = 'point' | 'circle' | 'rect' | 'cone' | 'path' | 'orbit'
/** 混合模式 */
export type Blend = 'normal' | 'add' | 'multiply' | 'screen'
/** 粒子属性取值方式 */
export type AttributeMode = 'fixed' | 'random' | 'curve'
/** 初始旋转额外支持跟随发射方向 */
export type RotationMode = AttributeMode | 'direction'
export type ScaleAxisMode = 'uniform' | 'separate'
export interface CurvePoint { x: number; y: number; inX?: number; inY?: number; outX?: number; outY?: number }
export interface ColorGradientStop { position: number; color: string }
export type ColliderShape = 'circle' | 'ellipse' | 'rect' | 'polygon' | 'custom'
export type CollisionResponse = 'physics' | 'pause' | 'destroy'
export interface SceneCollider {
id: number
shape: ColliderShape
enabled: boolean
x: number
y: number
rotation: number
scaleX: number
scaleY: number
radius: number
radiusX: number
radiusY: number
width: number
height: number
sides: number
response: CollisionResponse
elasticity: number
friction: number
}
export interface ParticleImageResource {
id: number
texture: Texture<any> | null
textureName: string
textureFolder: string
previewUrl: string
/** 图片模式:固定图片或按粒子出生时间播放的序列帧。 */
imageMode: 'fixed' | 'sequence'
/** 序列帧播放方式:单次隐藏、单次定格、正向循环或反向循环。 */
sequencePlayback: 'once-hide' | 'once-hold' | 'loop-forward' | 'loop-reverse'
/** 序列帧每秒播放帧数。 */
sequenceFps: number
sequenceFrames: ParticleSequenceFrame[]
/** 发射权重,多个资源按权重随机选择 */
weight: number
/** 锁定后,调整其他资源时保持当前占比 */
locked: boolean
scale: number
anchorX: number
anchorY: number
/** 每张贴图独立的颜色、混合与透明度设置 */
colorMode: 'fixed' | 'random' | 'lifetime'
colorStart: string
colorEnd: string
colorGradient: ColorGradientStop[]
blend: Blend
independentAlpha: 'off' | 'fixed' | 'curve'
alpha: number
alphaCurveMode: '跟随导出' | '线性' | '贝塞尔'
alphaCurve: CurvePoint[]
}
export interface ParticleSequenceFrame {
name: string
previewUrl: string
texture: Texture<any> | null
}
export interface TrailImageResource {
id: number
texture: Texture<any> | null
textureName: string
previewUrl: string
weight: number
locked: boolean
/** 图片默认方向,修改后重新生成 UV 并绑定蒙皮 */
rotation: number
colorMode: 'particle' | 'fixed' | 'curve'
color: string
colorGradient: ColorGradientStop[]
blend: Blend
alphaMode: 'particle' | 'fixed' | 'curve'
alpha: number
alphaCurve: CurvePoint[]
boneCount: number
/** 选中该资源的粒子实际生成拖尾的比例(0-100)。 */
trailRatio: number
lengthMode: 'fixed' | 'random'
length: number
lengthMin: number
lengthMax: number
widthSizeMode: 'fixed' | 'random'
width: number
widthMin: number
widthMax: number
widthMode: 'particle' | 'fixed'
lifeLengthEnabled: boolean
lifeLengthCurve: CurvePoint[]
shapeEnabled: boolean
shapeCurve: CurvePoint[]
}
/** 粒子配置(引用保持,面板改字段即实时生效) */
export interface EmitterConfig {
texture: Texture<any>
imageResources: ParticleImageResource[]
imageWeightVersion: number
name: string
// 发射
mode: EmitMode
rate: number // 持续:每秒发射数
/** 持续发射表现:常规持续或可无缝衔接的循环段。 */
streamBehavior: 'normal' | 'loop'
/** 循环持续的独立时长(帧),不覆盖常规持续的 duration 秒数。 */
loopDurationFrames: number
/** 循环持续模式下是否生成循环开始动画。 */
generateLoopStartAnimation: boolean
/** 是否手动指定进入循环稳定状态的时长;关闭时按最长粒子生命周期计算。 */
loopStartUseCustomDuration: boolean
/** 进入循环稳定状态的时长(帧)。 */
loopStartDurationFrames: number
/** 循环持续模式下是否生成循环结束动画。 */
generateLoopEndAnimation: boolean
/** 是否手动指定从循环稳定状态消亡的时长;关闭时按最长粒子生命周期计算。 */
loopEndUseCustomDuration: boolean
/** 从循环稳定状态消亡的时长(帧)。 */
loopEndDurationFrames: number
burstCount: number // 爆发:单次数量
burstLoop: boolean // 爆发是否每秒重复
/** 延迟发射(秒)= 粒子条左端位置 */
delay: number
/** 持续发射时长(秒)= 时间轴绿色段;爆发模式不使用该时长 */
duration: number
shape: EmitShape // 发射形状
/** 发射范围结构版本,用于旧配置迁移。 */
shapeRangeVersion: number
/** 圆形和锥形发射范围的内外半径。 */
innerRadius: number
outerRadius: number
/** 矩形发射范围:外矩形减去内矩形。 */
innerRectW: number
innerRectH: number
outerRectW: number
outerRectH: number
/** 粒子出生路径;与修改器中的发射器整体跟随路径完全独立。 */
spawnPathId: number
spawnPathDistribution: 'fixed' | 'random'
/** 旧版形状尺寸字段,保留用于配置迁移。 */
radius: number
rectW: number
rectH: number
/** 发射器中心 X/Y */
centerX: number
centerY: number
// 根节点(root)变换:作用于整个粒子系统(旋转/缩放),与场景对象参数联动
rootRotation: number
rootScaleX: number
rootScaleY: number
// 方向
emissionAngleMode: EmissionAngleMode
direction: number
spread: number
/** 锥形区域朝向;与粒子发射角度独立。 */
coneDirection: number
/** 锥形开口角度(发电器形状=锥体时,粒子在锥形扇区内均匀散开) */
coneAngle: number
// 属性
lifeMin: number
lifeMax: number
lifeMode: AttributeMode
lifeCurve: CurvePoint[]
speedMin: number
speedMax: number
speedMode: AttributeMode
speedCurve: CurvePoint[]
scaleMin: number
scaleMax: number
scaleMode: AttributeMode
scaleAxisMode: ScaleAxisMode
scaleYMin: number
scaleYMax: number
scaleCurve: CurvePoint[]
scaleYCurve: CurvePoint[]
scaleEndRatio: number
initialRotationMode: RotationMode
initialRotationMin: number
initialRotationMax: number
initialRotationCurve: CurvePoint[]
alpha: number
alphaEnd: number
rotationSpeedMin: number
rotationSpeedMax: number
// 生命周期内属性
speedOverLifeEnabled: boolean
speedOverLifeMode: AttributeMode
speedOverLifeMin: number
speedOverLifeMax: number
speedOverLifeCurve: CurvePoint[]
scaleOverLifeEnabled: boolean
scaleOverLifeMode: AttributeMode
scaleOverLifeAxisMode: ScaleAxisMode
scaleOverLifeMin: number
scaleOverLifeMax: number
scaleYOverLifeMin: number
scaleYOverLifeMax: number
scaleOverLifeCurve: CurvePoint[]
scaleYOverLifeCurve: CurvePoint[]
rotationOverLifeEnabled: boolean
rotationOverLifeMode: AttributeMode
rotationOverLifeCurve: CurvePoint[]
// 物理
/** 旧版重力值,保留用于配置迁移 */
gravity: number
gravityMode: 'fixed' | 'random'
gravityMin: number
gravityMax: number
/** 数学角度:0 向右,-90 向屏幕下方 */
gravityDirection: number
/** 生命周期内重力倍率,纵轴范围 0~1 */
gravityCurve: CurvePoint[]
/** 阻尼(速度衰减系数,0=无) */
damping: number
/** 全局随机种子;包括 0 在内的任意整数都可确定性复现 */
seed: number
// 颜色
colorStart: string
colorEnd: string
colorOverLife: boolean
// 混合
blend: Blend
/** 透明度模式:fixed(固定)/ curve(曲线) */
alphaMode: 'fixed' | 'curve'
/** 曲线模式(透明度插值方式) */
curveMode: '跟随导出' | '线性' | '贝塞尔'
/** 透明度曲线关键帧:x=生命周期时刻(0~1),y=透明度(0~1,1=完全不透明) */
alphaCurve: { x: number; y: number }[]
/** 图片名称(贴图资源名,默认 dot) */
textureName: string
/** 子文件夹(图片资源路径,占位) */
textureFolder: string
/** 占比(0~1) */
alphaScale: number
/** 贴图锚点/轴心 x,y(0~1) */
anchorX: number
anchorY: number
/** 独立透明度:off / fixed / curve */
independentAlpha: 'off' | 'fixed' | 'curve'
// 发射点偏移
offsetX: number
offsetY: number
// ===== 修改器 (Modifiers) =====
/** 开启移动噪声 */
noise: boolean
/** 噪声影响比例(0~100%) */
noiseAmt: number
/** 粒子偏离原轨迹的最大距离(px) */
noiseRange: number
/** 噪声每秒变化速度 */
noiseSpeed: number
/** 开启重力(由 gravity 控制) */
useGravity: boolean
/** 开启风力 */
wind: boolean
/** 旧版二维风力,保留用于配置迁移 */
windX: number
windY: number
windMode: 'fixed' | 'random'
windMin: number
windMax: number
/** 数学角度:0 向右,-90 向屏幕下方 */
windDirection: number
/** 生命周期内风力倍率,纵轴范围 0~1 */
windCurve: CurvePoint[]
/** 开启力场(吸力/斥力) */
forceField: boolean
forceStrength: number
forceRadius: number
/** 力场中心使用场景全局数学坐标 */
forceCenterX: number
forceCenterY: number
forceFalloff: 'constant' | 'linear' | 'inverse'
/** 开启吸附:粒子在延迟后从当前位置移动到全局吸附点,到达范围后死亡 */
attraction: boolean
attractionCenterX: number
attractionCenterY: number
attractionRadius: number
attractionDuration: number
attractionDelay: number
attractionDelayMode: 'global' | 'particle'
attractionPath: 'linear' | 'curve'
/** 开启拖尾 */
trail: boolean
trailConfigVersion: number
trailResources: TrailImageResource[]
trailResourceVersion: number
/** 拖尾使用独立图片和独立网格蒙皮 */
/** @deprecated 旧版单拖尾资源字段,仅用于配置迁移 */
trailTexture: Texture<any> | null
trailTextureName: string
trailPreviewUrl: string
trailColorMode: 'particle' | 'fixed' | 'curve'
trailColor: string
trailColorGradient: ColorGradientStop[]
trailAlphaMode: 'particle' | 'fixed' | 'curve'
trailAlpha: number
trailAlphaCurve: CurvePoint[]
/** 每个粒子的同级拖尾骨骼数量 */
trailBoneCount: number
trailLength: number
trailWidth: number
trailWidthMode: 'particle' | 'fixed'
trailLifeLengthEnabled: boolean
trailLifeLengthCurve: CurvePoint[]
trailShapeEnabled: boolean
trailShapeCurve: CurvePoint[]
/** 蒙皮网格拓扑尺寸 */
trailGridRows: number
trailGridCols: number
/** 开启碰撞;仅 collisionColliderIds 中显式选中的场景碰撞体参与计算。 */
collision: boolean
collisionColliderIds: number[]
/** 开启路径跟随(预留) */
pathFollow: boolean
emitterFollow: boolean
emitterFollowMode: 'none' | 'path' | 'bone'
emitterFollowPathId: number
emitterFollowSpineId: number
emitterFollowBoneName: string
emitterFollowBoneOffsetX: number
emitterFollowBoneOffsetY: number
emitterFollowBoneRotation: number
emitterFollowBoneScaleX: number
emitterFollowBoneScaleY: number
emitterAngleMode: 'fixed' | 'direction'
emitterFollowCurve: CurvePoint[]
emitterFollowDuration: number
emitterFollowSpace: 'local' | 'world'
emitterFollowDirection: 'forward' | 'reverse'
emitterFollowOffset: number
}
/** 粒子瞬间状态(供骨骼同步 / 导出) */
export interface ParticleState {
/** 对应骨骼在 root 下的命名 p_<i> */
boneName: string
/** 每次出生唯一且可复现的编号;关闭导出骨骼池时用于区分同一池槽的多次复用。 */
spawnId?: number
x: number
y: number
rotation: number
scaleX: number
scaleY: number
/** 已烘焙的数学世界坐标;导出时不再依赖路径、碰撞体或 Spine。 */
worldX?: number
worldY?: number
worldRotation?: number
worldScaleX?: number
worldScaleY?: number
alpha: number
colorHex: number
resourceId?: number
/** 当前图片资源的序列帧索引,固定图片为 0。 */
imageFrameIndex?: number
/** 单次播放结束后可只隐藏图片,粒子和拖尾仍继续存在。 */
imageVisible?: boolean
/** 拖尾拥有一组同级骨骼和蒙皮状态,录制/回放时一并保存。 */
trailState?: TrailState
active: boolean
}
export interface TrailBoneState {
boneName: string
x: number
y: number
rotation: number
worldX?: number
worldY?: number
worldRotation?: number
}
export interface TrailState {
bones: TrailBoneState[]
/** 兼容旧配置;当前拖尾整组骨骼同时出生和死亡,存在时始终等于骨骼总数。 */
activeBoneCount?: number
width: number
alpha: number
colorHex: number
resourceId?: number
}
File diff suppressed because it is too large. Load diff
+17 -609
View File
@@ -129,6 +129,13 @@
</template>
<script setup lang="ts">
import { type BonePreview, type SceneTransformTarget } from './stage/stageTypes'
import { pathLocalToWorld, worldToPathLocal, type FollowCurvePoint, followCurveValue, cubicPoint } from './stage/stageGeometry'
import { niceStep, drawAxis } from './stage/axisGuides'
import { drawOrigin, drawShapeRange, drawForceField, drawAttraction } from './stage/emitterGuides'
import { drawBoneFollowTargets, drawGizmo, colorNumber, drawCollisionBodies, drawPaths } from './stage/sceneGuides'
import { appendStateBones, drawDirectionalAxis, drawBones } from './stage/boneGuides'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { Application, Container, Graphics, Sprite, Text, Texture } from 'pixi.js'
import { ParticleEmitter, ensureEmitterConfig, type ParticleState } from '../core/particleEmitter'
@@ -335,100 +342,6 @@ function clearBackground(resetControls = true) {
}
}
// 自适应刻度步进(仿时间轴):返回 1/2/5 序列里的最合适步长,保证屏幕间距合理
function niceStep(target: number, minStep = 0): number {
if (target <= 0) return 1
const pow = Math.pow(10, Math.floor(Math.log10(target)))
const candidates = [1, 2, 5, 10, 20, 50, 100]
let best = 1
for (const c of candidates) {
if (c * pow >= target) { best = c * pow; break }
}
return Math.max(best, minStep)
}
// 画无限长 x/y 轴 + 自适应整数刻度 + 数值(0,0 在画布中心)
function drawAxis() {
if (!axisGfx) return
const g = axisGfx
g.clear()
const S = store.settings
if (!S.tickEnabled) { // 未启用刻度:隐藏整个坐标轴(轴+刻度+数值)
for (const t of axisLabels) t.visible = false
return
}
const cx = 0, cy = 0 // 世界原点(中心)
const halfW = app!.screen.width / store.editor.viewScale
const halfH = app!.screen.height / store.editor.viewScale
const axisColor = Number('0x' + S.axisColor.replace('#', ''))
// 轴线(贯穿到视口边缘外)
g.lineStyle(1.5, axisColor, 0.35)
g.moveTo(-halfW, cy); g.lineTo(halfW, cy) // x 轴
g.moveTo(cx, -halfH); g.lineTo(cx, halfH) // y 轴
// 刻度步进:目标 ~50px 一个刻度(世界单位 = px/viewScale)
const sc = store.editor.viewScale
const step = niceStep(50 / sc)
// 数值显示范围:只在中心 ±range 内标数值(避免远端堆积/干扰);范围随视口半宽
const rangeW = halfW
const rangeH = halfH
// 刻度线(贯穿整个视口;仅启用刻度时画)
const tickW = Math.max(1, S.tickWidth)
if (S.tickEnabled) {
for (let x = 0; x <= halfW; x += step) {
const v = Math.round(x / step) * step
if (v === 0) continue
g.lineStyle(tickW, axisColor, 0.25)
g.moveTo(v, cy - 4); g.lineTo(v, cy + 4)
g.moveTo(-v, cy - 4); g.lineTo(-v, cy + 4)
}
for (let y = 0; y <= halfH; y += step) {
const v = Math.round(y / step) * step
if (v === 0) continue
g.lineStyle(tickW, axisColor, 0.25)
g.moveTo(cx - 4, y); g.lineTo(cx + 4, y)
g.moveTo(cx - 4, -y); g.lineTo(cx + 4, -y)
}
}
// 数值标签(文字放 axisLabelLayer,不随 world 缩放,尺寸/清晰度恒定)
// 只在中心 ±range 内标注数值,超出不显示,避免看不清/干扰
const cxS = app!.screen.width / 2 + store.editor.viewX
const cyS = app!.screen.height * 0.5 + store.editor.viewY
const fs = S.axisFontSize // 字号由系统设置手动控制(默认 12px)
const labelColor = Number('0x' + S.axisColor.replace('#', ''))
while (axisLabels.length < 64) {
const t = new Text('', { fontFamily: 'monospace', fontSize: fs, fill: labelColor })
t.anchor.set(0.5, 0.5)
axisLabelLayer!.addChild(t)
axisLabels.push(t)
}
let li = 0
const showLbl = S.showAxisLabels && S.tickEnabled
// x 轴正/负数值(放在轴下方一点)
for (let x = 0; x <= halfW; x += step) {
const v = Math.round(x / step) * step
if (v === 0 || !showLbl || v > rangeW) continue
const lbl = axisLabels[li++]; if (!lbl) break
lbl.style.fontSize = fs
lbl.text = String(v); lbl.position.set(cxS + v * sc, cyS + 9); lbl.visible = true
const lvb = axisLabels[li++]; if (!lvb) break
lvb.style.fontSize = fs
lvb.text = String(-v); lvb.position.set(cxS - v * sc, cyS + 9); lvb.visible = true
}
// y 轴(数值放在轴右侧)
for (let y = 0; y <= halfH; y += step) {
const v = Math.round(y / step) * step
if (v === 0 || !showLbl || v > rangeH) continue
const lbl = axisLabels[li++]; if (!lbl) break
lbl.style.fontSize = fs
lbl.text = String(v); lbl.position.set(cxS + 8, cyS - y * sc); lbl.visible = true
const lvb = axisLabels[li++]; if (!lvb) break
lvb.style.fontSize = fs
lvb.text = String(-v); lvb.position.set(cxS + 8, cyS + y * sc); lvb.visible = true
}
// 隐藏多余标签
while (li < axisLabels.length) { const t = axisLabels[li++]; if (t) t.visible = false }
}
// ==== 视图控制:滚轮缩放 / 左键拖拽平移 ====
// 滚轮缩放画布(viewScale),基于鼠标所在位置缩放更平滑
@@ -491,23 +404,6 @@ function pointerToWorldMath(clientX: number, clientY: number) {
}
}
function pathLocalToWorld(path: ScenePath, x: number, y: number) {
const angle = path.rotation * Math.PI / 180
const cos = Math.cos(angle), sin = Math.sin(angle)
const scaleX = Math.max(0.0001, Math.abs(path.scaleX ?? 1))
const scaleY = Math.max(0.0001, Math.abs(path.scaleY ?? 1))
return { x: path.x + cos * x * scaleX - sin * y * scaleY, y: path.y + sin * x * scaleX + cos * y * scaleY }
}
function worldToPathLocal(path: ScenePath, x: number, y: number) {
const angle = path.rotation * Math.PI / 180
const cos = Math.cos(angle), sin = Math.sin(angle)
const scaleX = Math.max(0.0001, Math.abs(path.scaleX ?? 1))
const scaleY = Math.max(0.0001, Math.abs(path.scaleY ?? 1))
const dx = x - path.x, dy = y - path.y
return { x: (cos * dx + sin * dy) / scaleX, y: (-sin * dx + cos * dy) / scaleY }
}
function activePath() {
if (store.activeObjectType !== 'path') return null
const path = store.paths.find((item) => item.id === store.activeObjectId) || null
@@ -740,17 +636,6 @@ function onAttractionDown(e: PointerEvent) {
return true
}
type SceneTransformTarget = {
x: number
y: number
rotation: number
scaleX: number
scaleY: number
setPosition: (x: number, y: number) => void
setRotation: (rotation: number) => void
setScale: (scaleX: number, scaleY: number) => void
}
function selectedTransformTarget(): SceneTransformTarget | null {
const id = store.activeObjectId
if (store.activeObjectType === 'particle') {
@@ -866,483 +751,6 @@ function syncEmitters() {
}
}
function drawOrigin() {
if (!originGfx) return
const g = originGfx
g.clear()
if (!store.settings.showEmitter) return
// 每个系统对应一个发射点(坐标点);没有系统时不画
if (!store.systems.length) return
for (const sys of store.systems) {
if (sys.visible === false) continue
const preview = emitterPreviewTransform.get(sys.id)
const cx = preview?.x ?? sys.config.centerX ?? 0
const cy = -(preview?.y ?? sys.config.centerY ?? 0)
const active = store.activeObjectType === 'particle' && sys.id === store.activeId
const col = active ? 0xffcc33 : 0x8a94b8
const opacity = active ? 0.4 : 0.2
g.lineStyle(1, col, opacity)
g.drawCircle(cx, cy, 10)
g.beginFill(col, active ? 0.8 : 0.4)
g.drawCircle(cx, cy, 3)
g.endFill()
g.lineStyle(1, col, active ? 0.3 : 0.15)
g.moveTo(cx - 16, cy); g.lineTo(cx + 16, cy)
g.moveTo(cx, cy - 16); g.lineTo(cx, cy + 16)
}
}
/** 标出层级树当前选中的骨骼;发射器骨骼跟随复用同一目标点。 */
function drawBoneFollowTargets() {
if (!boneFollowTargetGfx) return
const g = boneFollowTargetGfx
g.parent?.addChild(g)
g.clear()
const targets = new Map<string, { spineId: number; boneName: string }>()
for (const spine of store.spines) {
if (spine.visible === false || !spine.selectedBone || spine.status !== 'ready') continue
targets.set(`${spine.id}:${spine.selectedBone}`, { spineId: spine.id, boneName: spine.selectedBone })
}
for (const system of store.systems) {
if (system.visible === false) continue
const config = system.config
if (!config.emitterFollow || config.emitterFollowMode !== 'bone') continue
if (!config.emitterFollowSpineId || !config.emitterFollowBoneName) continue
const spine = store.spines.find((item) => item.id === config.emitterFollowSpineId)
if (!spine || spine.visible === false) continue
targets.set(`${config.emitterFollowSpineId}:${config.emitterFollowBoneName}`, {
spineId: config.emitterFollowSpineId,
boneName: config.emitterFollowBoneName,
})
}
for (const item of targets.values()) {
const target = spineRuntime?.getBoneWorldTransform(item.spineId, item.boneName)
if (!target) continue
const x = target.x
const y = -target.y
g.lineStyle(2, 0xc48cff, 0.95)
g.beginFill(0xc48cff, 0.2)
g.drawCircle(x, y, 8)
g.endFill()
g.beginFill(0xf2e7ff, 1)
g.drawCircle(x, y, 3.5)
g.endFill()
}
}
// 绘制当前选中系统的发射器形状范围(点/路径不额外绘制),随参数实时响应。
function drawShapeRange() {
if (!shapeGfx) return
const g = shapeGfx
g.clear()
if (!store.settings.showEmitter) return
const sys = store.systems.find((s) => s.id === store.activeId)
if (!sys || sys.visible === false) return
const c = sys.config
const preview = emitterPreviewTransform.get(sys.id)
// root 中心(发射器容器 position = centerX/-centerY),数学坐标
const cx = preview?.x ?? c.centerX ?? 0
const cy = -(preview?.y ?? c.centerY ?? 0)
const shape = c.shape
if (shape === 'point' || shape === 'path') return
// 相对 root 中心的局部点(lx,ly,数学坐标)先加偏移(发射源 = root 中心 + offset),再应用 root 缩放+旋转,加 root 中心 → 世界坐标
const sxr = c.rootScaleX ?? 1, syr = c.rootScaleY ?? 1
const rot = -(preview?.rotation ?? c.rootRotation ?? 0) * Math.PI / 180 // 数学上正 → 屏幕:逆时针为正
const cs = Math.cos(rot), sn = Math.sin(rot)
const oxo = c.offsetX ?? 0, oyo = c.offsetY ?? 0
const pt = (lx: number, ly: number): [number, number] => {
// 对齐粒子:局部 y 为数学上正 → 屏幕取负;发射源偏移 offsetY(数学上正)→ 屏幕也取负
const X = lx + oxo, Y = -ly - oyo
const x = X * sxr, y = Y * syr
return [cx + x * cs - y * sn, cy + (x * sn + y * cs)]
}
if (shape === 'circle') {
const range = normalizedRadialRange(c.innerRadius, c.outerRadius)
g.lineStyle(2, 0x3ce86a, 0.7)
// 圆环:以 root 变换后的椭圆近似(逐点)
const steps = 48
for (const radius of [range.outer, range.inner]) {
if (radius <= 0) continue
for (let i = 0; i <= steps; i++) {
const a = i / steps * Math.PI * 2
const [px, py] = pt(Math.cos(a) * radius, Math.sin(a) * radius)
if (i === 0) g.moveTo(px, py); else g.lineTo(px, py)
}
}
} else if (shape === 'rect') {
const range = normalizedRectRange(c)
g.lineStyle(2, 0x4a8cff, 0.7)
for (const [width, height] of [[range.outerW, range.outerH], [range.innerW, range.innerH]]) {
if (width <= 0 || height <= 0) continue
const hw = width / 2, hh = height / 2
const p1 = pt(-hw, -hh), p2 = pt(hw, -hh), p3 = pt(hw, hh), p4 = pt(-hw, hh)
g.moveTo(p1[0], p1[1]); g.lineTo(p2[0], p2[1]); g.lineTo(p3[0], p3[1]); g.lineTo(p4[0], p4[1]); g.lineTo(p1[0], p1[1])
}
} else if (shape === 'cone') {
const range = normalizedRadialRange(c.innerRadius, c.outerRadius)
const half = (c.coneAngle / 2) * Math.PI / 180
const base = c.coneDirection * Math.PI / 180
// 锥形环带的两条边、内弧和外弧。
const innerEdge1 = pt(Math.cos(base - half) * range.inner, Math.sin(base - half) * range.inner)
const innerEdge2 = pt(Math.cos(base + half) * range.inner, Math.sin(base + half) * range.inner)
const outerEdge1 = pt(Math.cos(base - half) * range.outer, Math.sin(base - half) * range.outer)
const outerEdge2 = pt(Math.cos(base + half) * range.outer, Math.sin(base + half) * range.outer)
g.lineStyle(2, 0x3ce86a, 0.7)
g.moveTo(innerEdge1[0], innerEdge1[1]); g.lineTo(outerEdge1[0], outerEdge1[1])
g.moveTo(innerEdge2[0], innerEdge2[1]); g.lineTo(outerEdge2[0], outerEdge2[1])
g.lineStyle(1, 0x3ce86a, 0.5)
const steps = 24
for (const radius of [range.outer, range.inner]) {
if (radius <= 0) continue
for (let i = 0; i <= steps; i++) {
const a = base - half + (2 * half * i / steps)
const [px, py] = pt(Math.cos(a) * radius, Math.sin(a) * radius)
if (i === 0) g.moveTo(px, py); else g.lineTo(px, py)
}
}
}
}
/** 绘制当前选中系统的全局力场;不应用粒子系统 root 变换。 */
function drawForceField() {
if (!forceFieldGfx) return
const g = forceFieldGfx
g.clear()
const sys = store.systems.find((item) => item.id === store.activeId)
if (sys?.visible === false) return
const cfg = sys?.config
if (!cfg?.forceField || cfg.forceRadius <= 0) return
const x = cfg.forceCenterX
const y = -cfg.forceCenterY
const color = cfg.forceStrength >= 0 ? 0x58c7ff : 0xff6b7a
g.lineStyle(2, color, 0.85)
g.beginFill(color, 0.08)
g.drawCircle(x, y, cfg.forceRadius)
g.endFill()
g.lineStyle(1, color, 0.7)
g.moveTo(x - 9, y); g.lineTo(x + 9, y)
g.moveTo(x, y - 9); g.lineTo(x, y + 9)
g.beginFill(color, 0.95)
g.drawCircle(x, y, 4)
g.endFill()
}
/** 绘制全局吸附点与吸附完成半径;半径为 0 时显示为红色点。 */
function drawAttraction() {
if (!attractionGfx) return
const g = attractionGfx
g.clear()
const sys = store.systems.find((item) => item.id === store.activeId)
if (sys?.visible === false) return
const cfg = sys?.config
if (!cfg?.attraction) return
const x = cfg.attractionCenterX
const y = -cfg.attractionCenterY
const radius = Math.max(0, cfg.attractionRadius)
const color = 0xff4d5f
if (radius > 0) {
g.lineStyle(2, color, 0.95)
g.beginFill(color, 0.09)
g.drawCircle(x, y, radius)
g.endFill()
}
g.lineStyle(1, color, 0.85)
g.moveTo(x - 10, y); g.lineTo(x + 10, y)
g.moveTo(x, y - 10); g.lineTo(x, y + 10)
g.beginFill(color, 1)
g.drawCircle(x, y, radius > 0 ? 4 : 6)
g.endFill()
}
// 当前场景对象的变换手柄:跟随其位置、旋转和缩放。
function drawGizmo() {
if (!gizmoGfx) return
const g = gizmoGfx
g.clear()
if (!activeTool.value) return
const target = selectedTransformTarget()
if (!target) return
const cx = target.x
const cy = -target.y
const sxr = target.scaleX, syr = target.scaleY
const rot = -target.rotation * Math.PI / 180
const cs = Math.cos(rot), sn = Math.sin(rot)
// 局部点(数学坐标,ly 上正) → 屏幕点(应用 root 缩放+旋转+平移到 root 中心)
const pt = (lx: number, ly: number): [number, number] => {
const x = lx * sxr, y = (-ly) * syr
return [cx + x * cs - y * sn, cy + (x * sn + y * cs)]
}
const line = (ax: number, ay: number, bx: number, by: number) => {
const a = pt(ax, ay), b = pt(bx, by)
g.moveTo(a[0], a[1]); g.lineTo(b[0], b[1])
}
const R = 26
// 中心点
g.lineStyle(2, 0x35d0ff, 0.9)
g.beginFill(0x35d0ff, 0.25)
g.drawCircle(cx, cy, 6)
g.endFill()
if (activeTool.value === 'translate') {
// 十字箭头
g.lineStyle(2, 0x35d0ff, 0.9)
line(-R, 0, R, 0); line(0, -R, 0, R)
line(R - 6, -4, R, 0); line(R, 0, R - 6, 4) // 右箭头
line(-(R - 6), -4, -R, 0); line(-R, 0, -(R - 6), 4) // 左箭头
line(-4, R - 6, 0, R); line(0, R, 4, R - 6) // 上箭头
line(-4, -(R - 6), 0, -R); line(0, -R, 4, -(R - 6)) // 下箭头
} else if (activeTool.value === 'rotate') {
// 旋转外圈(经缩放/旋转后可能是椭圆)
g.lineStyle(2, 0x35d0ff, 0.8)
const steps = 48
for (let i = 0; i <= steps; i++) {
const a = i / steps * Math.PI * 2
const [px, py] = pt(Math.cos(a) * R, Math.sin(a) * R)
if (i === 0) g.moveTo(px, py); else g.lineTo(px, py)
}
// 指向点:固定于局部顶部(0° 刻度在 12 点方向),经 pt(含 root 旋转)后随 rootRotation 旋转。
// 默认(rootRotation=0)时指向点位于顶部;旋转时它随 root 一起转,反映当前旋转角。
const [tx, ty] = pt(0, R)
g.beginFill(0x35d0ff, 0.95)
g.drawCircle(tx, ty, 4)
g.endFill()
} else if (activeTool.value === 'scale') {
// 四角方点(经缩放/旋转后为旋转矩形)
g.lineStyle(2, 0x35d0ff, 0.9)
const corners: [number, number][] = [[-R, -R], [R, -R], [R, R], [-R, R]]
const pts = corners.map(([ox, oy]) => pt(ox, oy))
g.moveTo(pts[0][0], pts[0][1]); g.lineTo(pts[1][0], pts[1][1]); g.lineTo(pts[2][0], pts[2][1]); g.lineTo(pts[3][0], pts[3][1]); g.lineTo(pts[0][0], pts[0][1])
g.beginFill(0x35d0ff, 0.9)
for (const p of pts) g.drawRect(p[0] - 3, p[1] - 3, 6, 6)
g.endFill()
}
}
// 粒子→骨骼映射:每个活动粒子 = 一根挂在 root 下的骨骼
type BonePreview = { boneName: string; x: number; y: number; rotation: number; parentX?: number; parentY?: number; trail?: boolean }
function appendStateBones(target: BonePreview[], state: ParticleState, parentX: number, parentY: number) {
// 粒子快照同时保存局部坐标和烘焙世界坐标。骨骼调试图层挂在 world 下,必须使用
// 世界坐标,才能与发生位移、旋转、缩放或跟随后的粒子保持逐点重合。
const hasWorldPosition = Number.isFinite(state.worldX) && Number.isFinite(state.worldY)
const hasWorldRotation = Number.isFinite(state.worldRotation)
target.push({
boneName: state.boneName,
x: hasWorldPosition ? state.worldX! : state.x,
y: hasWorldPosition ? -state.worldY! : state.y,
rotation: hasWorldRotation ? -state.worldRotation! : -state.rotation,
parentX,
parentY,
})
if (!state.trailState?.bones.length) return
state.trailState.bones.forEach((bone) => {
// 导出结构中拖尾骨骼全部直接挂在粒子系统骨骼下,画布连线也保持同级语义。
const hasBoneWorldPosition = Number.isFinite(bone.worldX) && Number.isFinite(bone.worldY)
const hasBoneWorldRotation = Number.isFinite(bone.worldRotation)
target.push({
boneName: bone.boneName,
x: hasBoneWorldPosition ? bone.worldX! : bone.x,
y: hasBoneWorldPosition ? -bone.worldY! : bone.y,
rotation: hasBoneWorldRotation ? -bone.worldRotation! : bone.rotation,
parentX,
parentY,
trail: true,
})
})
}
function drawDirectionalAxis(
g: Graphics,
x: number,
y: number,
directionX: number,
directionY: number,
length: number,
width: number,
color: number,
) {
const negativeLength = length * 0.62
const tipX = x + directionX * length
const tipY = y + directionY * length
const headLength = Math.max(2.5, length * 0.32)
const headWidth = Math.max(1.7, length * 0.22)
const baseX = tipX - directionX * headLength
const baseY = tipY - directionY * headLength
const perpendicularX = -directionY
const perpendicularY = directionX
g.lineStyle(width, color, 0.95)
g.moveTo(x - directionX * negativeLength, y - directionY * negativeLength)
g.lineTo(baseX, baseY)
g.lineStyle(0)
g.beginFill(color, 0.95)
g.drawPolygon([
tipX, tipY,
baseX + perpendicularX * headWidth, baseY + perpendicularY * headWidth,
baseX - perpendicularX * headWidth, baseY - perpendicularY * headWidth,
])
g.endFill()
}
function drawBones(bones: BonePreview[]) {
if (!boneGfx) return
const g = boneGfx
g.clear()
if (!store.settings.showBones) return
const cx = 0, cy = 0
const viewScale = Math.max(0.25, Math.abs(store.editor.viewScale) || 1)
const pointSize = Math.min(10, Math.max(0.1, Number(store.settings.bonePointDrawSize) || 1))
const axisSize = Math.min(10, Math.max(0.1, Number(store.settings.boneAxisDrawSize) || 2))
// 所有尺寸先定义为屏幕像素,再除以画布缩放,保证缩放画布时视觉大小不变。
const pointRadius = 2.5 * pointSize / viewScale
const forwardLength = 4.5 * axisSize / viewScale
const forwardWidth = Math.max(0.65, axisSize * 0.625) / viewScale
// root → 每颗粒子骨骼 的连线
for (const b of bones) {
const color = b.trail ? 0xff4d8d : 0xff8c5a
g.lineStyle(1, color, b.trail ? 0.65 : 0.35)
g.moveTo(b.parentX ?? cx, b.parentY ?? cy)
g.lineTo(b.x, b.y)
// 局部坐标轴为两根交叉轴线,仅在 +X、+Y 端绘制箭头。
const angle = b.rotation * Math.PI / 180
const cos = Math.cos(angle), sin = Math.sin(angle)
drawDirectionalAxis(g, b.x, b.y, cos, sin, forwardLength, forwardWidth, 0xef5350)
drawDirectionalAxis(g, b.x, b.y, sin, -cos, forwardLength, forwardWidth, 0x55c96b)
// 骨骼点
g.beginFill(color, 0.9)
g.drawCircle(b.x, b.y, pointRadius)
g.endFill()
}
}
function colorNumber(value: string, fallback = 0xffffff) {
const parsed = Number.parseInt(value.replace('#', ''), 16)
return Number.isFinite(parsed) ? parsed : fallback
}
function drawCollisionBodies() {
if (!collisionGfx) return
const g = collisionGfx
g.clear()
for (const collider of previewColliders) {
if (collider.visible === false) continue
const selected = store.activeObjectType === 'collision' && store.activeObjectId === collider.id
const color = collider.enabled ? (selected ? 0xff5e72 : 0xd4475b) : 0x586174
const angle = collider.rotation * Math.PI / 180
const cos = Math.cos(angle), sin = Math.sin(angle)
const scaleX = Math.max(0.0001, Math.abs(collider.scaleX ?? 1))
const scaleY = Math.max(0.0001, Math.abs(collider.scaleY ?? 1))
const point = (x: number, y: number) => ({
x: collider.x + cos * x * scaleX - sin * y * scaleY,
y: -(collider.y + sin * x * scaleX + cos * y * scaleY),
})
let points: Array<{ x: number; y: number }> = []
if (collider.shape === 'circle' || collider.shape === 'ellipse') {
const radiusX = collider.shape === 'circle' ? collider.radius : collider.radiusX
const radiusY = collider.shape === 'circle' ? collider.radius : collider.radiusY
points = Array.from({ length: 49 }, (_, index) => {
const theta = index / 48 * Math.PI * 2
return point(Math.cos(theta) * radiusX, Math.sin(theta) * radiusY)
})
} else if (collider.shape === 'rect') {
const hw = collider.width * 0.5, hh = collider.height * 0.5
points = [point(-hw, -hh), point(hw, -hh), point(hw, hh), point(-hw, hh), point(-hw, -hh)]
} else {
const sides = Math.max(3, Math.min(16, Math.round(collider.sides)))
points = Array.from({ length: sides + 1 }, (_, index) => {
const theta = Math.PI * 0.5 + (index % sides) / sides * Math.PI * 2
return point(Math.cos(theta) * collider.width * 0.5, Math.sin(theta) * collider.height * 0.5)
})
}
if (!points.length) continue
g.lineStyle(selected ? 2.5 : 1.5, color, collider.enabled ? 0.95 : 0.45)
g.beginFill(color, selected ? 0.12 : 0.055)
g.moveTo(points[0].x, points[0].y)
for (let i = 1; i < points.length; i++) g.lineTo(points[i].x, points[i].y)
g.endFill()
const center = point(0, 0)
g.lineStyle(1, color, 0.8)
g.moveTo(center.x - 7, center.y); g.lineTo(center.x + 7, center.y)
g.moveTo(center.x, center.y - 7); g.lineTo(center.x, center.y + 7)
}
}
function drawPaths() {
if (!pathGfx) return
const g = pathGfx
g.clear()
for (const path of store.paths) {
if (path.visible === false || !path.enabled || !path.points.length) continue
const selected = store.activeObjectType === 'path' && store.activeObjectId === path.id
const color = colorNumber(path.color, 0xff933e)
const screenPoint = (x: number, y: number) => {
const worldPoint = pathLocalToWorld(path, x, y)
return { x: worldPoint.x, y: -worldPoint.y }
}
const first = path.points[0]
const start = screenPoint(first.x, first.y)
g.lineStyle(selected ? 2.5 : 1.8, color, selected ? 1 : 0.75)
g.moveTo(start.x, start.y)
const segmentCount = path.closed ? path.points.length : path.points.length - 1
for (let i = 0; i < segmentCount; i++) {
const a = path.points[i]
const b = path.points[(i + 1) % path.points.length]
const controlA = screenPoint(a.x + a.outX, a.y + a.outY)
const controlB = screenPoint(b.x + b.inX, b.y + b.inY)
const end = screenPoint(b.x, b.y)
g.bezierCurveTo(controlA.x, controlA.y, controlB.x, controlB.y, end.x, end.y)
}
if (!selected) continue
path.points.forEach((anchor, index) => {
const point = screenPoint(anchor.x, anchor.y)
const input = screenPoint(anchor.x + anchor.inX, anchor.y + anchor.inY)
const output = screenPoint(anchor.x + anchor.outX, anchor.y + anchor.outY)
const hasInput = Math.hypot(anchor.inX, anchor.inY) > 0.01
const hasOutput = Math.hypot(anchor.outX, anchor.outY) > 0.01
g.lineStyle(1, 0xa9b4cb, 0.65)
if (hasInput) { g.moveTo(point.x, point.y); g.lineTo(input.x, input.y) }
if (hasOutput) { g.moveTo(point.x, point.y); g.lineTo(output.x, output.y) }
if (hasInput) { g.beginFill(0xd5dcec, 0.95); g.drawCircle(input.x, input.y, 3); g.endFill() }
if (hasOutput) { g.beginFill(0xd5dcec, 0.95); g.drawCircle(output.x, output.y, 3); g.endFill() }
g.beginFill(selectedPathNodes.has(index) ? 0xffffff : color, 1)
g.lineStyle(1.5, selectedPathNodes.has(index) ? 0x5d7cff : 0xffffff, 0.95)
g.drawCircle(point.x, point.y, selectedPathNodes.has(index) ? 5 : 4)
g.endFill()
})
}
}
type FollowCurvePoint = { x: number; y: number; inX?: number; inY?: number; outX?: number; outY?: number }
function followCurveValue(points: FollowCurvePoint[], t: number, mode: 'linear' | 'bezier' = 'linear') {
if (!points.length) return t
const sorted = [...points].sort((a, b) => a.x - b.x)
if (t <= sorted[0].x) return sorted[0].y
for (let index = 1; index < sorted.length; index++) {
const a = sorted[index - 1], b = sorted[index]
if (t <= b.x) {
let amount = (t - a.x) / Math.max(0.0001, b.x - a.x)
if (mode === 'bezier') {
const x1 = a.x + (a.outX ?? (b.x - a.x) / 3)
const x2 = b.x + (b.inX ?? -(b.x - a.x) / 3)
let low = 0, high = 1
for (let step = 0; step < 18; step++) {
const middle = (low + high) / 2
if (cubicPoint(a.x, x1, x2, b.x, middle) < t) low = middle
else high = middle
}
amount = (low + high) / 2
return cubicPoint(a.y, a.y + (a.outY ?? (b.y - a.y) / 3), b.y + (b.inY ?? -(b.y - a.y) / 3), b.y, amount)
}
return a.y + (b.y - a.y) * amount
}
}
return sorted[sorted.length - 1].y
}
function cubicPoint(a: number, b: number, c: number, d: number, t: number) {
const u = 1 - t
return u * u * u * a + 3 * u * u * t * b + 3 * u * t * t * c + t * t * t * d
}
function buildPreviewColliders(timeSeconds: number) {
previewColliders = store.colliders.map((collider) => {
if (!collider.followPath || !collider.followPathId) return collider
@@ -1410,7 +818,7 @@ function loop() {
backgroundSprite.alpha = Math.min(1, Math.max(0, Number(store.settings.backgroundOpacity) || 0))
}
drawAxis()
drawAxis(store, app, axisGfx, axisLabelLayer, axisLabels)
syncEmitters()
// 根节点(root)变换:位置(centerX/centerY)=位移,rotation/scale 绕 root 中心,与场景对象参数联动
const followFps = Math.max(1, store.timeline.fps)
@@ -1638,15 +1046,15 @@ function loop() {
timelineDisplayFrame = displayFrame
spineRuntime?.sync(store.spines, displayFrame, fps)
for (const [, emitter] of emitterMap) emitter.drawTrailMeshDebug(store.settings.showSkinMesh)
drawCollisionBodies()
drawPaths()
drawBoneFollowTargets()
drawOrigin()
drawShapeRange()
drawForceField()
drawAttraction()
drawGizmo()
drawBones(collected)
drawCollisionBodies(store, previewColliders, collisionGfx)
drawPaths(store, selectedPathNodes, pathGfx)
drawBoneFollowTargets(store, spineRuntime, boneFollowTargetGfx)
drawOrigin(store, emitterPreviewTransform, originGfx)
drawShapeRange(store, emitterPreviewTransform, shapeGfx)
drawForceField(store, forceFieldGfx)
drawAttraction(store, attractionGfx)
drawGizmo(activeTool, selectedTransformTarget, gizmoGfx)
drawBones(collected, store, boneGfx)
if (info.value) { info.value.systems = store.systems.length; info.value.particles = total }
}
}
@@ -0,0 +1,182 @@
<template>
<!-- 2. 发射模式 -->
<div class="group">
<div class="group-head" @click="toggle('emitMode')">
<span class="caret">{{ collapsed.emitMode ? '▸' : '▾' }}</span><span>发射模式</span>
<button class="group-reset" title="重置发射模式参数" @click.stop="resetModule('emitMode')">重置</button>
</div>
<div class="group-body" v-show="!collapsed.emitMode">
<div class="segs">
<button class="seg" :class="{ on: sys.config.mode === 'stream' }" @click="setEmitMode('stream')">持续</button>
<button class="seg" :class="{ on: sys.config.mode === 'burst' }" @click="setEmitMode('burst')">爆发</button>
</div>
<template v-if="sys.config.mode === 'stream'">
<div class="segs stream-behavior-segs">
<button class="seg" :class="{ on: sys.config.streamBehavior === 'normal' }" @click="setStreamBehavior('normal')">常规持续</button>
<button class="seg" :class="{ on: sys.config.streamBehavior === 'loop' }" @click="setStreamBehavior('loop')">循环持续</button>
</div>
<NumSlider label="发射速率" :min="1" :max="300" :step="1" v-model="sys.config.rate" />
<NumSlider v-if="sys.config.streamBehavior === 'normal'" label="发射时长" :min="1 / 30" :max="60" :step="1 / 30" v-model="sys.config.duration" />
<NumSlider v-else label="发射时长 (f)" :min="1" :max="1800" :step="1" :model-value="loopDurationFrames" @update:model-value="setLoopDurationFrames" />
<div v-if="sys.config.streamBehavior === 'loop'" class="stream-loop-options">
<div class="loop-section-row">
<label class="switch-row">
<input type="checkbox" :checked="sys.config.generateLoopStartAnimation" @change="setLoopSection('start', $event)" />
<span class="switch-ui"></span><span>生成开始循环动画</span>
</label>
<div v-if="sys.config.generateLoopStartAnimation" class="loop-duration-control">
<label class="switch-row loop-duration-toggle">
<input type="checkbox" :checked="sys.config.loopStartUseCustomDuration" @change="setLoopCustomDuration('start', $event)" />
<span class="switch-ui"></span><span>指定时长</span>
</label>
<label class="loop-section-duration">
<input v-if="sys.config.loopStartUseCustomDuration" :value="sys.config.loopStartDurationFrames" type="number" min="1" max="1800" step="1" aria-label="开始循环段时长" @change="setLoopSectionDuration('start', $event)" /><span v-if="sys.config.loopStartUseCustomDuration">f</span>
<span v-else class="loop-auto-duration">自动 {{ automaticLoopDuration }}f</span>
</label>
</div>
</div>
<div class="loop-section-row">
<label class="switch-row">
<input type="checkbox" :checked="sys.config.generateLoopEndAnimation" @change="setLoopSection('end', $event)" />
<span class="switch-ui"></span><span>生成结束循环动画</span>
</label>
<div v-if="sys.config.generateLoopEndAnimation" class="loop-duration-control">
<label class="switch-row loop-duration-toggle">
<input type="checkbox" :checked="sys.config.loopEndUseCustomDuration" @change="setLoopCustomDuration('end', $event)" />
<span class="switch-ui"></span><span>指定时长</span>
</label>
<label class="loop-section-duration">
<input v-if="sys.config.loopEndUseCustomDuration" :value="sys.config.loopEndDurationFrames" type="number" min="1" max="1800" step="1" aria-label="结束循环段时长" @change="setLoopSectionDuration('end', $event)" /><span v-if="sys.config.loopEndUseCustomDuration">f</span>
<span v-else class="loop-auto-duration">自动 {{ automaticLoopDuration }}f</span>
</label>
</div>
</div>
</div>
<div v-if="sys.config.streamBehavior === 'loop'" class="modifier-hint stream-loop-hint">循环持续模式不支持骨骼跟随和路径跟随;进入或退出时会将粒子跟随设置恢复默认值。</div>
</template>
<template v-else>
<NumSlider label="单次数量" :min="1" :max="1000" :step="1" v-model="sys.config.burstCount" />
</template>
<NumSlider label="延迟时间" :min="0" :max="5" :step="0.1" v-model="sys.config.delay" />
</div>
</div>
<!-- 3. 发射器形状 -->
<div class="group">
<div class="group-head" @click="toggle('shape')">
<span class="caret">{{ collapsed.shape ? '▸' : '▾' }}</span><span>发射器形状</span>
<button class="group-reset" title="重置发射器形状参数" @click.stop="resetModule('shape')">重置</button>
</div>
<div class="group-body" v-show="!collapsed.shape">
<label class="row"><span>形状</span>
<select v-model="sys.config.shape" class="inp" @change="onShapeChange">
<option value="point">点</option>
<option value="circle">圆形</option>
<option value="rect">矩形</option>
<option value="cone">锥形</option>
<option value="path">路径</option>
</select>
</label>
<template v-if="sys.config.shape === 'circle' || sys.config.shape === 'cone'">
<NumSlider :label="sys.config.shape === 'cone' ? '内径' : '内半径'" :min="0" :max="sys.config.outerRadius" :step="1" v-model="sys.config.innerRadius" />
<NumSlider :label="sys.config.shape === 'cone' ? '外径' : '外半径'" :min="sys.config.innerRadius" :max="300" :step="1" v-model="sys.config.outerRadius" />
</template>
<template v-if="sys.config.shape === 'cone'">
<NumSlider label="锥形朝向" :min="-360" :max="360" :step="1" v-model="sys.config.coneDirection" />
<NumSlider label="锥形开口角度" :min="1" :max="360" :step="1" v-model="sys.config.coneAngle" />
</template>
<template v-else-if="sys.config.shape === 'rect'">
<NumSlider label="内宽" :min="0" :max="sys.config.outerRectW" :step="1" v-model="sys.config.innerRectW" />
<NumSlider label="内高" :min="0" :max="sys.config.outerRectH" :step="1" v-model="sys.config.innerRectH" />
<NumSlider label="外宽" :min="sys.config.innerRectW" :max="400" :step="1" v-model="sys.config.outerRectW" />
<NumSlider label="外高" :min="sys.config.innerRectH" :max="400" :step="1" v-model="sys.config.outerRectH" />
</template>
<template v-else-if="sys.config.shape === 'path'">
<label class="row"><span>选择路径</span>
<select v-model.number="sys.config.spawnPathId" class="inp" @change="onSpawnPathChange">
<option :value="0">无路径(点发射)</option>
<option v-for="path in store.paths" :key="path.id" :value="path.id" :disabled="!path.enabled">{{ path.name }}</option>
</select>
</label>
<label v-if="sys.config.spawnPathId > 0" class="row"><span>出生分布</span>
<select v-model="sys.config.spawnPathDistribution" class="inp">
<option value="fixed">等间距固定分布</option>
<option value="random">随机分布</option>
</select>
</label>
</template>
<div class="divider"></div>
<label class="row"><span>发射角度</span>
<select v-model="sys.config.emissionAngleMode" class="inp">
<option value="fixed">固定</option>
<option value="random">随机</option>
<option v-if="sys.config.shape === 'circle' || sys.config.shape === 'rect' || sys.config.shape === 'cone'" value="radial">径向(中心向外)</option>
<option v-if="sys.config.shape === 'path' && sys.config.spawnPathId > 0" value="path-normal">垂直于路径</option>
<option v-if="sys.config.shape === 'path' && sys.config.spawnPathId > 0" value="path-tangent">沿路径方向</option>
</select>
</label>
<NumSlider v-if="sys.config.emissionAngleMode === 'fixed'" label="固定角度" :min="-360" :max="360" :step="1" v-model="sys.config.direction" />
<template v-else-if="sys.config.emissionAngleMode === 'random'">
<NumSlider label="基准角度" :min="-360" :max="360" :step="1" v-model="sys.config.direction" />
<NumSlider label="随机范围" :min="0" :max="360" :step="1" v-model="sys.config.spread" />
</template>
</div>
</div>
</template>
<script setup lang="ts">
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 { useParticlePanelContext } from './particlePanelContext'
const { store, sys, automaticLoopDuration, loopDurationFrames, collapsed, toggle, setEmitMode, setLoopDurationFrames, setStreamBehavior, setLoopSection, setLoopSectionDuration, setLoopCustomDuration, resetModule, select, onShapeChange, onSpawnPathChange } = useParticlePanelContext()
</script>
<style scoped>
.group { border: 1px solid #2a2a3c; border-radius: 8px; margin-bottom: 10px; overflow: hidden; }
.group-head {
display: flex; align-items: center; gap: 6px; padding: 7px 10px; cursor: pointer;
font-size: 12px; font-weight: 600; color: #ccd; background: #191924; user-select: none;
}
.group-head:hover { background: #20202e; }
.group-reset {
margin-left: auto; height: 22px; padding: 0 7px; border: 1px solid #3a465f; border-radius: 5px;
background: #252d3d; color: #9eabc3; font-size: 10px; line-height: 20px; cursor: pointer;
}
.group-reset:hover { border-color: #7774ff; background: #45426f; color: #fff; }
.group-body { padding: 8px 10px 12px; }
.caret { display: inline-block; width: 12px; color: #667; font-size: 11px; }
.divider { height: 1px; background: #2a2a3c; margin: 8px 0; }
.row { display: flex; align-items: center; gap: 8px; margin: 6px 0; font-size: 12px; color: #aab; }
.row > span:first-child { width: 60px; flex-shrink: 0; color: #8a93bb; }
.inp { flex: 1; min-width: 0; padding: 3px 5px; background: #1a1a28; border: 1px solid #2e2e44; border-radius: 5px; color: #dde; font-size: 12px; }
.segs { display: flex; gap: 6px; margin: 6px 0; }
.stream-behavior-segs { margin-top: 10px; }
.stream-loop-options { display: grid; gap: 8px; margin: 8px 0 4px; padding: 9px 10px; border: 1px solid #32405a; border-radius: 6px; background: #171f2e; }
.loop-section-row { display: flex; min-height: 27px; align-items: center; justify-content: space-between; gap: 8px; }
.loop-duration-control { display: flex; flex: 0 0 auto; align-items: center; gap: 7px; }
.loop-duration-toggle { gap: 5px; font-size: 10px; white-space: nowrap; }
.loop-duration-toggle .switch-ui { width: 26px; height: 14px; }
.loop-duration-toggle .switch-ui::after { width: 10px; height: 10px; }
.loop-duration-toggle input:checked + .switch-ui::after { transform: translateX(12px); }
.loop-section-duration { display: flex; flex: 0 0 auto; align-items: center; gap: 4px; color: #7f8ba4; font-size: 11px; }
.loop-section-duration input { position: static; width: 54px; height: 25px; padding: 0 6px; border: 1px solid #3a465f; border-radius: 5px; outline: none; background: #101725; color: #e4e8f2; text-align: right; opacity: 1; pointer-events: auto; }
.loop-section-duration input:focus { border-color: #716eff; }
.loop-auto-duration { min-width: 54px; color: #77839a; text-align: right; white-space: nowrap; }
.seg { flex: 1; padding: 5px; background: #1e1e2e; border: 1px solid #2e2e44; border-radius: 6px; color: #aab; font-size: 12px; cursor: pointer; }
.seg.on { background: #3a5a8c; color: #fff; border-color: #5a7ab8; }
.seg { display: flex; gap: 6px; margin: 6px 0; }
.inp.tiny { width: 46px; flex: 0 0 46px; text-align: center; }
.switch-row { display: flex; align-items: center; gap: 9px; color: #b8c1d5; font-size: 12px; cursor: pointer; }
.switch-row input { position: absolute; opacity: 0; pointer-events: none; }
.switch-ui { position: relative; width: 32px; height: 18px; border-radius: 10px; background: #354258; transition: background .16s ease; }
.switch-ui::after { content: ''; position: absolute; left: 2px; top: 2px; width: 14px; height: 14px; border-radius: 50%; background: #fff; transition: transform .16s ease; }
.switch-row input:checked + .switch-ui { background: #625ff1; }
.switch-row input:checked + .switch-ui::after { transform: translateX(14px); }
.modifier-hint { margin: 7px 0 0 2px; color: #647086; font-size: 9px; line-height: 1.5; }
.stream-loop-hint { margin: 9px 2px 3px; padding: 7px 8px; border: 1px solid #4a436e; border-radius: 5px; background: #211f35; color: #aaa4d7; }
.switch-row input:disabled + .switch-ui { opacity: 0.45; cursor: not-allowed; }
</style>
@@ -0,0 +1,110 @@
<template>
<!-- 7. 导出设置:当前阶段只维护参数,后续接入 JSON 生成。 -->
<div class="group">
<div class="group-head" @click="toggle('export')">
<span class="caret">{{ collapsed.export ? '▸' : '▾' }}</span><span>导出设置</span>
<button class="group-reset" title="重置导出设置" @click.stop="resetModule('export')">重置</button>
</div>
<div class="group-body export-settings" v-show="!collapsed.export">
<label class="export-field">
<span>图片路径 (Spine Images Path)</span>
<input v-model.trim="exportSettings.imagesPath" class="inp export-input" type="text" placeholder="./images/" />
</label>
<label class="export-field">
<span>Spine 版本</span>
<select v-model="exportSettings.spineVersion" class="inp export-input">
<option value="3.8">Spine 3.8</option>
<option value="4.0">Spine 4.0</option>
<option value="4.1">Spine 4.1</option>
<option value="4.2">Spine 4.2</option>
<option value="4.3">Spine 4.3</option>
</select>
</label>
<label class="export-field">
<span>关键帧插值曲线</span>
<select v-model="exportSettings.keyframeCurve" class="inp export-input">
<option value="linear">线性 (Linear)</option>
<option value="bezier">平滑贝塞尔 (Smooth Bézier)</option>
</select>
</label>
<div class="export-help">平滑贝塞尔会在误差允许范围内拟合连续运动;出生、死亡、附件切换和突变仍使用阶跃关键帧。</div>
<div v-if="exportSettings.spineVersion === '3.8'" class="export-help">Spine 3.8 不支持 4.x 的逐通道数组贝塞尔,导出时会转换为经过容差精简的分段线性关键帧。</div>
<div class="export-fps-row">
<label class="export-field export-fps-field">
<span>导出 FPS</span>
<input v-model.number="exportSettings.fps" class="inp export-input" type="number" min="1" max="240" step="1" @change="normalizeExportFps" />
</label>
<label class="export-checkbox"><input v-model="exportSettings.omitFps" type="checkbox" />不写 fps</label>
</div>
<div class="export-help">修改 FPS 时保持动画实际秒数不变;“不写 fps”只省略 Spine 的 fps 元数据。</div>
<div class="export-switches">
<label class="switch-row export-switch">
<input v-model="exportSettings.integerFrameAlignment" type="checkbox" />
<span class="switch-ui"></span><span>整数帧对齐</span>
</label>
<label class="switch-row export-switch">
<input v-model="exportSettings.bonePool" type="checkbox" />
<span class="switch-ui"></span><span>骨骼对象池</span>
</label>
<label class="switch-row export-switch export-switch-last">
<input v-model="exportSettings.excludeEmptyAnimations" type="checkbox" />
<span class="switch-ui"></span><span>排除无内容动画</span>
</label>
</div>
</div>
</div>
</template>
<script setup lang="ts">
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 { useParticlePanelContext } from './particlePanelContext'
const { exportSettings, collapsed, toggle, resetModule, normalizeExportFps, select } = useParticlePanelContext()
</script>
<style scoped>
.group { border: 1px solid #2a2a3c; border-radius: 8px; margin-bottom: 10px; overflow: hidden; }
.group-head {
display: flex; align-items: center; gap: 6px; padding: 7px 10px; cursor: pointer;
font-size: 12px; font-weight: 600; color: #ccd; background: #191924; user-select: none;
}
.group-head:hover { background: #20202e; }
.group-reset {
margin-left: auto; height: 22px; padding: 0 7px; border: 1px solid #3a465f; border-radius: 5px;
background: #252d3d; color: #9eabc3; font-size: 10px; line-height: 20px; cursor: pointer;
}
.group-reset:hover { border-color: #7774ff; background: #45426f; color: #fff; }
.group-body { padding: 8px 10px 12px; }
.caret { display: inline-block; width: 12px; color: #667; font-size: 11px; }
.row { display: flex; align-items: center; gap: 8px; margin: 6px 0; font-size: 12px; color: #aab; }
.row > span:first-child { width: 60px; flex-shrink: 0; color: #8a93bb; }
.inp { flex: 1; min-width: 0; padding: 3px 5px; background: #1a1a28; border: 1px solid #2e2e44; border-radius: 5px; color: #dde; font-size: 12px; }
.placeholder { color: #667; font-size: 11px; padding: 6px 0; }
.export-settings { padding-top: 3px; }
.export-field { display: flex; flex-direction: column; gap: 6px; margin: 10px 0 13px; color: #9ca8bf; font-size: 11px; font-weight: 600; }
.export-input { width: 100%; min-height: 34px; box-sizing: border-box; padding: 6px 9px; color: #e3e7f2; background: #141a29; border-color: #35445f; }
.export-fps-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: end; }
.export-fps-field { margin-bottom: 5px; }
.export-checkbox { display: flex; align-items: center; gap: 7px; min-height: 34px; margin-bottom: 5px; color: #aeb8cc; font-size: 11px; white-space: nowrap; }
.export-checkbox input { width: 17px; height: 17px; margin: 0; accent-color: #625ff1; }
.export-help { margin: -6px 0 14px; color: #66758e; font-size: 10px; line-height: 1.5; }
.export-switches { display: grid; gap: 13px; margin: 4px 0 7px; padding-top: 2px; }
.export-switch { font-weight: 600; }
.export-switch-last { margin-top: 13px; }
.inp.tiny { width: 46px; flex: 0 0 46px; text-align: center; }
.switch-row { display: flex; align-items: center; gap: 9px; color: #b8c1d5; font-size: 12px; cursor: pointer; }
.switch-row input { position: absolute; opacity: 0; pointer-events: none; }
.switch-ui { position: relative; width: 32px; height: 18px; border-radius: 10px; background: #354258; transition: background .16s ease; }
.switch-ui::after { content: ''; position: absolute; left: 2px; top: 2px; width: 14px; height: 14px; border-radius: 50%; background: #fff; transition: transform .16s ease; }
.switch-row input:checked + .switch-ui { background: #625ff1; }
.switch-row input:checked + .switch-ui::after { transform: translateX(14px); }
.switch-row input:disabled + .switch-ui { opacity: 0.45; cursor: not-allowed; }
</style>
@@ -0,0 +1,157 @@
<template>
<!-- 5. 外观与资源 -->
<div class="group">
<div class="group-head" @click="toggle('look')">
<span class="caret">{{ collapsed.look ? '▸' : '▾' }}</span><span>外观与资源</span>
<button class="group-reset" title="重置外观与资源参数" @click.stop="resetModule('look')">重置</button>
</div>
<div class="group-body" v-show="!collapsed.look">
<!-- 透明度 -->
<div class="look-title">透明度</div>
<div class="seg">
<button class="seg-btn" :class="{ active: sys.config.alphaMode === 'curve' }" @click="sys.config.alphaMode = 'curve'">曲线</button>
<button class="seg-btn" :class="{ active: sys.config.alphaMode === 'fixed' }" @click="sys.config.alphaMode = 'fixed'">固定</button>
</div>
<template v-if="sys.config.alphaMode === 'fixed'">
<NumSlider label="透明度" :min="0" :max="1" :step="0.01" v-model="sys.config.alpha" />
</template>
<template v-else>
<label class="row"><span>曲线模式</span>
<select v-model="sys.config.curveMode" class="inp">
<option>跟随导出</option><option>线性</option><option>贝塞尔</option>
</select>
</label>
<div class="curve-wrap">
<CurveEditor v-model="sys.config.alphaCurve" :default-value="ALPHA_CURVE" :mode="resolvedCurveEditorMode(sys.config.curveMode)" />
<div class="curve-labels"><span>0(透明)</span><span>生命周期 →</span><span>1(不透明)</span></div>
</div>
</template>
<!-- 图片资源 -->
<div class="look-title image-title">
<span>图片资源</span>
<button class="image-add" title="新增图片资源" @click="addImageResource">+</button>
</div>
<ImageResourceCard
v-for="resource in sys.config.imageResources"
:key="resource.id"
:resource="resource"
:resource-count="sys.config.imageResources.length"
supports-sequence
default-file="star.png"
@pick="openTexturePicker"
@pick-sequence="openSequencePicker"
@reset="resetTexture"
@remove="removeImageResource"
@update-weight="setResourceWeight"
@toggle-lock="toggleResourceLock"
>
<NumSlider label="缩放" :min="0.1" :max="3" :step="0.05" v-model="resource.scale" />
<label class="row"><span>轴心 x,y</span><input type="number" step="0.05" class="inp tiny" v-model.number="resource.anchorX" /><input type="number" step="0.05" class="inp tiny" v-model.number="resource.anchorY" /></label>
<div class="resource-divider"></div>
<label class="row resource-color-row"><span>颜色</span>
<select v-model="resource.colorMode" class="inp">
<option value="fixed">固定色</option>
<option value="random">随机颜色</option>
<option value="lifetime">生命周期</option>
</select>
<select v-model="resource.blend" class="inp blend-select">
<option value="normal">正常</option>
<option value="add">相加</option>
<option value="multiply">相乘</option>
<option value="screen">滤色</option>
</select>
</label>
<template v-if="resource.colorMode === 'fixed'">
<label class="row resource-color-picker"><span>固定色</span><input type="color" v-model="resource.colorStart" class="colr" /><input type="text" class="hex-input" :value="resource.colorStart" spellcheck="false" aria-label="固定颜色值" @change="setResourceFixedColor(resource, $event)" @keydown.enter="commitResourceFixedColor(resource, $event)" /></label>
</template>
<template v-else>
<ColorGradientEditor v-model="resource.colorGradient" />
<div v-if="resource.colorMode === 'random'" class="random-color-hint">每个粒子出生时从色带随机取色,生命周期内保持不变</div>
</template>
<div class="resource-alpha-head">
<span>独立透明度</span>
<div class="alpha-modes">
<button :class="{ on: resource.independentAlpha === 'off' }" @click="resource.independentAlpha = 'off'">关闭</button>
<button :class="{ on: resource.independentAlpha === 'fixed' }" @click="resource.independentAlpha = 'fixed'">固定</button>
<button :class="{ on: resource.independentAlpha === 'curve' }" @click="resource.independentAlpha = 'curve'">曲线</button>
</div>
</div>
<NumSlider v-if="resource.independentAlpha === 'fixed'" label="透明度" :min="0" :max="1" :step="0.01" v-model="resource.alpha" />
<template v-else-if="resource.independentAlpha === 'curve'">
<label class="row"><span>曲线模式</span>
<select v-model="resource.alphaCurveMode" class="inp">
<option>跟随导出</option><option>线性</option><option>贝塞尔</option>
</select>
</label>
<div class="resource-alpha-curve">
<CurveEditor v-model="resource.alphaCurve" :default-value="RESOURCE_ALPHA_CURVE" :mode="resolvedCurveEditorMode(resource.alphaCurveMode)" />
</div>
</template>
</ImageResourceCard>
<input ref="textureFileInput" class="file-input" type="file" accept="image/png,image/jpeg,image/webp,image/gif" @change="onTextureFile" />
<input ref="sequenceFileInput" class="file-input" type="file" multiple accept="image/png,image/jpeg,image/webp,image/gif" @change="onSequenceFiles" />
</div>
</div>
</template>
<script setup lang="ts">
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 { useParticlePanelContext } from './particlePanelContext'
const { resolvedCurveEditorMode, sys, textureFileInput, sequenceFileInput, addImageResource, openTexturePicker, openSequencePicker, onTextureFile, onSequenceFiles, resetTexture, setResourceFixedColor, commitResourceFixedColor, removeImageResource, setResourceWeight, toggleResourceLock, collapsed, toggle, resetModule, select, ALPHA_CURVE, RESOURCE_ALPHA_CURVE } = useParticlePanelContext()
</script>
<style scoped>
.group { border: 1px solid #2a2a3c; border-radius: 8px; margin-bottom: 10px; overflow: hidden; }
.group-head {
display: flex; align-items: center; gap: 6px; padding: 7px 10px; cursor: pointer;
font-size: 12px; font-weight: 600; color: #ccd; background: #191924; user-select: none;
}
.group-head:hover { background: #20202e; }
.group-reset {
margin-left: auto; height: 22px; padding: 0 7px; border: 1px solid #3a465f; border-radius: 5px;
background: #252d3d; color: #9eabc3; font-size: 10px; line-height: 20px; cursor: pointer;
}
.group-reset:hover { border-color: #7774ff; background: #45426f; color: #fff; }
.group-body { padding: 8px 10px 12px; }
.caret { display: inline-block; width: 12px; color: #667; font-size: 11px; }
.divider { height: 1px; background: #2a2a3c; margin: 8px 0; }
.row { display: flex; align-items: center; gap: 8px; margin: 6px 0; font-size: 12px; color: #aab; }
.row > span:first-child { width: 60px; flex-shrink: 0; color: #8a93bb; }
.inp { flex: 1; min-width: 0; padding: 3px 5px; background: #1a1a28; border: 1px solid #2e2e44; border-radius: 5px; color: #dde; font-size: 12px; }
.colr { width: 28px; height: 22px; border: none; background: none; cursor: pointer; }
.seg { flex: 1; padding: 5px; background: #1e1e2e; border: 1px solid #2e2e44; border-radius: 6px; color: #aab; font-size: 12px; cursor: pointer; }
.seg.on { background: #3a5a8c; color: #fff; border-color: #5a7ab8; }
.look-title { font-size: 12px; font-weight: 600; color: #ccd; margin: 10px 0 6px; padding-bottom: 4px; border-bottom: 1px solid #2a2a3c; }
.look-title:first-child { margin-top: 2px; }
.seg { display: flex; gap: 6px; margin: 6px 0; }
.seg-btn { flex: 1; padding: 5px; background: #1e1e2e; border: 1px solid #2e2e44; border-radius: 6px; color: #aab; font-size: 12px; cursor: pointer; }
.seg-btn.active { background: #3a5a8c; color: #fff; border-color: #5a7ab8; }
.curve-wrap { margin: 6px 0; }
.curve-labels { display: flex; justify-content: space-between; color: #566; font-size: 10px; margin-top: 2px; }
.image-title { display: flex; align-items: center; justify-content: space-between; }
.image-add { width: 26px; height: 24px; border: 1px solid #4a4d9a; border-radius: 5px; background: #252850; color: #8f8cff; font-size: 18px; line-height: 1; cursor: pointer; }
.image-add:hover { background: #4542a8; color: #fff; }
.resource-divider { height: 1px; margin: 9px 0 7px; background: #2a3549; }
.resource-color-row .blend-select { max-width: 88px; }
.resource-color-picker .hex { margin-left: 0; }
.random-color-hint { margin: -5px 0 9px 68px; color: #66758e; font-size: 9px; line-height: 1.45; }
.hex-input { width: 72px; padding: 4px 6px; border: 1px solid #344158; border-radius: 5px; outline: none; background: #101725; color: #aeb8cb; font-size: 11px; font-family: monospace; }
.hex-input:focus { border-color: #7774ff; color: #fff; }
.resource-alpha-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 10px; color: #8a93bb; font-size: 12px; }
.alpha-modes { display: flex; gap: 3px; }
.alpha-modes button { padding: 4px 8px; border: 1px solid transparent; border-radius: 5px; background: transparent; color: #8d98ae; font-size: 11px; cursor: pointer; }
.alpha-modes button:hover { background: #273246; }
.alpha-modes button.on { border-color: #7774ff; background: #605df0; color: #fff; }
.resource-alpha-curve { margin-top: 7px; }
.file-input { display: none; }
.hex { font-size: 11px; color: #889; margin-left: auto; font-variant-numeric: tabular-nums; }
.inp.tiny { width: 46px; flex: 0 0 46px; text-align: center; }
</style>
@@ -0,0 +1,196 @@
<template>
<!-- 4. 粒子属性 -->
<div class="group">
<div class="group-head" @click="toggle('attr')">
<span class="caret">{{ collapsed.attr ? '▸' : '▾' }}</span><span>粒子属性</span>
<button class="group-reset" title="重置粒子属性参数" @click.stop="resetModule('attr')">重置</button>
</div>
<div class="group-body attr-body" v-show="!collapsed.attr">
<div class="attr-section-title">粒子初始参数</div>
<ParticleAttributeControl
label="初始生命周期"
:allow-curve="false"
v-model:mode="sys.config.lifeMode"
v-model:value-min="sys.config.lifeMin"
v-model:value-max="sys.config.lifeMax"
v-model:curve="sys.config.lifeCurve"
:min="0.1" :max="10" :step="0.1"
curve-label="随机采样 →"
/>
<ParticleAttributeControl
label="初始速度"
:allow-curve="false"
v-model:mode="sys.config.speedMode"
v-model:value-min="sys.config.speedMin"
v-model:value-max="sys.config.speedMax"
v-model:curve="sys.config.speedCurve"
:min="0" :max="1000" :step="1"
curve-label="随机采样 →"
/>
<div class="attr-axis-head">
<span>初始缩放</span>
<div class="axis-modes">
<button class="axis-mode" :class="{ on: sys.config.scaleAxisMode === 'uniform' }" @click="sys.config.scaleAxisMode = 'uniform'">统一</button>
<button class="axis-mode" :class="{ on: sys.config.scaleAxisMode === 'separate' }" @click="sys.config.scaleAxisMode = 'separate'">XY分离</button>
</div>
</div>
<ParticleAttributeControl
:label="sys.config.scaleAxisMode === 'uniform' ? '初始缩放' : '缩放 X'"
:allow-curve="false"
v-model:mode="sys.config.scaleMode"
v-model:value-min="sys.config.scaleMin"
v-model:value-max="sys.config.scaleMax"
v-model:curve="sys.config.scaleCurve"
:min="0" :max="5" :step="0.05"
curve-label="随机采样 →"
/>
<ParticleAttributeControl
v-if="sys.config.scaleAxisMode === 'separate'"
label="缩放 Y"
:show-modes="false"
:allow-curve="false"
v-model:mode="sys.config.scaleMode"
v-model:value-min="sys.config.scaleYMin"
v-model:value-max="sys.config.scaleYMax"
v-model:curve="sys.config.scaleYCurve"
:min="0" :max="5" :step="0.05"
curve-label="随机采样 →"
/>
<ParticleAttributeControl
label="初始旋转"
allow-direction
:allow-curve="false"
v-model:mode="sys.config.initialRotationMode"
v-model:value-min="sys.config.initialRotationMin"
v-model:value-max="sys.config.initialRotationMax"
v-model:curve="sys.config.initialRotationCurve"
:min="-360" :max="360" :step="1"
curve-label="随机采样 →"
/>
<div class="attr-section-title life-title">粒子生命周期变化参数</div>
<div class="life-item">
<label class="switch-row">
<input type="checkbox" v-model="sys.config.speedOverLifeEnabled" />
<span class="switch-ui"></span><span>生命周期内速度</span>
</label>
<ParticleAttributeControl
v-if="sys.config.speedOverLifeEnabled"
label="速度倍率"
:mode="sys.config.speedOverLifeMode"
@update:mode="setSpeedOverLifeMode"
v-model:value-min="sys.config.speedOverLifeMin"
v-model:value-max="sys.config.speedOverLifeMax"
v-model:curve="sys.config.speedOverLifeCurve"
:default-curve="FLAT_CURVE"
:min="0" :max="3" :step="0.05"
curve-label="生命周期 →"
/>
</div>
<div class="life-item">
<label class="switch-row">
<input type="checkbox" v-model="sys.config.scaleOverLifeEnabled" @change="prepareScaleOverLifeCurve" />
<span class="switch-ui"></span><span>生命周期内缩放</span>
</label>
<template v-if="sys.config.scaleOverLifeEnabled">
<div class="axis-modes life-axis">
<button class="axis-mode" :class="{ on: sys.config.scaleOverLifeAxisMode === 'uniform' }" @click="sys.config.scaleOverLifeAxisMode = 'uniform'">统一</button>
<button class="axis-mode" :class="{ on: sys.config.scaleOverLifeAxisMode === 'separate' }" @click="sys.config.scaleOverLifeAxisMode = 'separate'">XY分离</button>
</div>
<ParticleAttributeControl
:label="sys.config.scaleOverLifeAxisMode === 'uniform' ? '缩放倍率' : '倍率 X'"
:mode="sys.config.scaleOverLifeMode"
@update:mode="setScaleOverLifeMode"
v-model:value-min="sys.config.scaleOverLifeMin"
v-model:value-max="sys.config.scaleOverLifeMax"
v-model:curve="sys.config.scaleOverLifeCurve"
:default-curve="FLAT_CURVE"
:min="0" :max="3" :step="0.05"
curve-label="生命周期 →"
/>
<ParticleAttributeControl
v-if="sys.config.scaleOverLifeAxisMode === 'separate'"
label="倍率 Y"
:show-modes="false"
v-model:mode="sys.config.scaleOverLifeMode"
v-model:value-min="sys.config.scaleYOverLifeMin"
v-model:value-max="sys.config.scaleYOverLifeMax"
v-model:curve="sys.config.scaleYOverLifeCurve"
:default-curve="FLAT_CURVE"
:min="0" :max="3" :step="0.05"
curve-label="生命周期 →"
/>
</template>
</div>
<div class="life-item">
<label class="switch-row">
<input type="checkbox" v-model="sys.config.rotationOverLifeEnabled" />
<span class="switch-ui"></span><span>生命周期内旋转</span>
</label>
<ParticleAttributeControl
v-if="sys.config.rotationOverLifeEnabled"
label="旋转速度"
v-model:mode="sys.config.rotationOverLifeMode"
v-model:value-min="sys.config.rotationSpeedMin"
v-model:value-max="sys.config.rotationSpeedMax"
v-model:curve="sys.config.rotationOverLifeCurve"
:default-curve="LINEAR_CURVE"
:min="-720" :max="720" :step="10"
curve-label="生命周期 →"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
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 { useParticlePanelContext } from './particlePanelContext'
const { sys, collapsed, toggle, resetModule, setSpeedOverLifeMode, prepareScaleOverLifeCurve, setScaleOverLifeMode, LINEAR_CURVE, FLAT_CURVE } = useParticlePanelContext()
</script>
<style scoped>
.group { border: 1px solid #2a2a3c; border-radius: 8px; margin-bottom: 10px; overflow: hidden; }
.group-head {
display: flex; align-items: center; gap: 6px; padding: 7px 10px; cursor: pointer;
font-size: 12px; font-weight: 600; color: #ccd; background: #191924; user-select: none;
}
.group-head:hover { background: #20202e; }
.group-reset {
margin-left: auto; height: 22px; padding: 0 7px; border: 1px solid #3a465f; border-radius: 5px;
background: #252d3d; color: #9eabc3; font-size: 10px; line-height: 20px; cursor: pointer;
}
.group-reset:hover { border-color: #7774ff; background: #45426f; color: #fff; }
.group-body { padding: 8px 10px 12px; }
.caret { display: inline-block; width: 12px; color: #667; font-size: 11px; }
.row { display: flex; align-items: center; gap: 8px; margin: 6px 0; font-size: 12px; color: #aab; }
.row > span:first-child { width: 60px; flex-shrink: 0; color: #8a93bb; }
.attr-body { padding: 10px; background: #151b27; }
.attr-section-title { margin: 2px 0 8px; color: #9ca8c0; font-size: 12px; font-weight: 700; }
.attr-section-title.life-title { margin-top: 14px; padding-top: 12px; border-top: 1px solid #2d3950; }
.attr-axis-head { display: flex; align-items: center; justify-content: space-between; margin: 10px 1px -2px; color: #aeb8cf; font-size: 12px; font-weight: 600; }
.axis-modes { display: flex; gap: 4px; }
.axis-mode { padding: 4px 8px; border: 1px solid #344158; border-radius: 5px; background: #273246; color: #aeb8cf; font-size: 11px; cursor: pointer; }
.axis-mode.on { border-color: #7774ff; background: #605df0; color: #fff; }
.life-item { padding: 9px 0; border-bottom: 1px solid #2a3549; }
.switch-row { display: flex; align-items: center; gap: 9px; color: #b8c1d5; font-size: 12px; cursor: pointer; }
.switch-row input { position: absolute; opacity: 0; pointer-events: none; }
.switch-ui { position: relative; width: 32px; height: 18px; border-radius: 10px; background: #354258; transition: background .16s ease; }
.switch-ui::after { content: ''; position: absolute; left: 2px; top: 2px; width: 14px; height: 14px; border-radius: 50%; background: #fff; transition: transform .16s ease; }
.switch-row input:checked + .switch-ui { background: #625ff1; }
.switch-row input:checked + .switch-ui::after { transform: translateX(14px); }
.life-axis { margin: 9px 0 2px 42px; }
.switch-row input:disabled + .switch-ui { opacity: 0.45; cursor: not-allowed; }
</style>
@@ -0,0 +1,61 @@
<template>
<div class="spine-export-actions">
<button type="button" class="export-spine-json" :disabled="exportBusy" :title="exportBusy ? '正在重新计算并烘焙动画' : `导出 Spine ${exportSettings.spineVersion} JSON`" @click="exportSpineJson">
{{ exportBusy && exportTarget === 'json' ? '正在准备导出…' : '导出Spine Json' }}
</button>
<button type="button" class="export-spine-images" :disabled="exportBusy" title="单独下载当前粒子系统使用的图片 ZIP" @click="exportSpineImages">
{{ exportBusy && exportTarget === 'images' ? '正在整理图片…' : '导出 images 包' }}
</button>
<button type="button" class="export-spine-project" :disabled="exportBusy || !connectorReady" :title="connectorButtonTitle" @click="exportSpineProject">
<span>{{ exportBusy && exportTarget === 'spine' ? '正在生成工程…' : '导出 .spine 包' }}</span>
<small v-if="!exportBusy && connectorDisabledReason">{{ connectorDisabledReason }}</small>
</button>
</div>
<div class="connector-status" :class="connectorStatusClass">
<span class="connector-dot"></span>{{ connectorStatusText }}
<a v-if="connectorAuthorizationUrl" :href="connectorAuthorizationUrl" target="_blank" rel="noopener" title="在本机连接器中授权当前网站">授权当前网站</a>
<button type="button" title="重新检测连接器" @click="refreshConnectorHealth">刷新</button>
</div>
<div v-if="exportMessage" class="export-message" :class="{ error: exportError }">
{{ exportMessage }}
<a v-if="connectorPairUrl" :href="connectorPairUrl" target="_blank" rel="noopener" @click="connectorPairUrl = ''">授权当前网站</a>
</div>
</template>
<script setup lang="ts">
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 { useParticlePanelContext } from './particlePanelContext'
const { exportSettings, exportBusy, exportTarget, exportMessage, exportError, connectorPairUrl, connectorStatusText, connectorStatusClass, connectorReady, connectorAuthorizationUrl, connectorDisabledReason, connectorButtonTitle, refreshConnectorHealth, exportSpineJson, exportSpineProject, exportSpineImages } = useParticlePanelContext()
</script>
<style scoped>
.spine-export-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; margin-top: 10px; }
.export-spine-json { width: 100%; padding: 9px 8px; flex: 0 0 auto; border: 1px solid #4f4b9a; border-radius: 7px; background: #403b91; color: #f1f0ff; font-size: 12px; font-weight: 700; cursor: pointer; }
.export-spine-json:hover { border-color: #7774ff; background: #514bc0; }
.export-spine-json:disabled { cursor: wait; opacity: .68; }
.export-spine-project { grid-column: 1 / -1; width: 100%; padding: 7px 8px; border: 1px solid #287d69; border-radius: 7px; background: #196854; color: #edfff9; font-size: 12px; font-weight: 700; cursor: pointer; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; }
.export-spine-project small { color: inherit; font-size: 9px; font-weight: 500; line-height: 1.1; }
.export-spine-project:hover:not(:disabled) { border-color: #47c89f; background: #238267; }
.export-spine-project:disabled { cursor: not-allowed; opacity: .48; }
.export-spine-images { width: 100%; padding: 8px; border: 1px solid #3b5f8b; border-radius: 7px; background: #243e64; color: #eaf3ff; font-size: 12px; font-weight: 700; cursor: pointer; }
.export-spine-images:hover:not(:disabled) { border-color: #6099dc; background: #315581; }
.export-spine-images:disabled { cursor: wait; opacity: .55; }
.connector-status { display: flex; align-items: center; justify-content: center; gap: 5px; padding-top: 6px; color: #717b99; font-size: 10px; }
.connector-status .connector-dot { width: 6px; height: 6px; border-radius: 50%; background: #66718f; }
.connector-status.ready { color: #70d8ae; }
.connector-status.ready .connector-dot { background: #49c993; box-shadow: 0 0 5px #49c99388; }
.connector-status.warning { color: #d4aa67; }
.connector-status.warning .connector-dot { background: #d4aa67; }
.connector-status button { padding: 0 3px; border: 0; background: transparent; color: #8290b6; font-size: 10px; cursor: pointer; }
.connector-status button:hover { color: #c5caff; }
.connector-status a { padding: 0 3px; color: #a8b0ff; font-size: 10px; text-decoration: underline; }
.connector-status a:hover { color: #d8dcff; }
.export-message { padding: 7px 5px 0; color: #70d8ae; font-size: 10px; line-height: 1.45; text-align: center; }
.export-message.error { color: #ff858f; }
.export-message a { display: inline-block; margin-left: 5px; color: #9da7ff; text-decoration: underline; }
</style>
@@ -0,0 +1,433 @@
<template>
<!-- 6. 修改器 -->
<div class="group">
<div class="group-head" @click="toggle('mods')">
<span class="caret">{{ collapsed.mods ? '▸' : '▾' }}</span><span>修改器</span>
<button class="group-reset" title="重置修改器参数" @click.stop="resetModule('mods')">重置</button>
</div>
<div class="group-body" v-show="!collapsed.mods">
<div class="modifier-item">
<label class="switch-row modifier-switch">
<input type="checkbox" v-model="sys.config.noise" />
<span class="switch-ui"></span><span>开启移动噪声</span>
</label>
<div v-if="sys.config.noise" class="modifier-params">
<NumSlider label="强度" :min="0" :max="100" :step="1" v-model="sys.config.noiseAmt" />
<NumSlider label="波动范围" :min="0" :max="500" :step="1" v-model="sys.config.noiseRange" />
<NumSlider label="时间流速" :min="0" :max="10" :step="0.1" v-model="sys.config.noiseSpeed" />
<div class="modifier-hint">强度控制影响比例 · 波动范围控制最大偏移 · 时间流速控制变化速度</div>
</div>
</div>
<div class="modifier-item">
<label class="switch-row modifier-switch">
<input type="checkbox" v-model="sys.config.useGravity" />
<span class="switch-ui"></span><span>开启重力</span>
</label>
<div v-if="sys.config.useGravity" class="modifier-params gravity-params">
<div class="modifier-mode-row">
<button class="axis-mode" :class="{ on: sys.config.gravityMode === 'fixed' }" @click="sys.config.gravityMode = 'fixed'">固定</button>
<button class="axis-mode" :class="{ on: sys.config.gravityMode === 'random' }" @click="sys.config.gravityMode = 'random'">随机</button>
</div>
<NumSlider v-if="sys.config.gravityMode === 'fixed'" label="重力强度" :min="-3000" :max="3000" :step="10" v-model="sys.config.gravityMin" />
<template v-else>
<NumSlider label="强度 min" :min="-3000" :max="3000" :step="10" v-model="sys.config.gravityMin" />
<NumSlider label="强度 max" :min="-3000" :max="3000" :step="10" v-model="sys.config.gravityMax" />
</template>
<NumSlider label="方向" :min="-360" :max="360" :step="1" v-model="sys.config.gravityDirection" />
<div class="gravity-curve-title"><span>生命周期内重力</span><span>纵轴:强度 0~1</span></div>
<CurveEditor v-model="sys.config.gravityCurve" :default-value="FLAT_CURVE" />
<div class="gravity-curve-axis"><span>0</span><span>生命周期 →</span><span>1</span></div>
<div class="modifier-hint">0° 向右 · -90° 向下 · 负强度会反转方向</div>
</div>
</div>
<div class="modifier-item">
<label class="switch-row modifier-switch">
<input type="checkbox" v-model="sys.config.wind" />
<span class="switch-ui"></span><span>开启风力</span>
</label>
<div v-if="sys.config.wind" class="modifier-params gravity-params">
<div class="modifier-mode-row">
<button class="axis-mode" :class="{ on: sys.config.windMode === 'fixed' }" @click="sys.config.windMode = 'fixed'">固定</button>
<button class="axis-mode" :class="{ on: sys.config.windMode === 'random' }" @click="sys.config.windMode = 'random'">随机</button>
</div>
<NumSlider v-if="sys.config.windMode === 'fixed'" label="风力强度" :min="-3000" :max="3000" :step="10" v-model="sys.config.windMin" />
<template v-else>
<NumSlider label="强度 min" :min="-3000" :max="3000" :step="10" v-model="sys.config.windMin" />
<NumSlider label="强度 max" :min="-3000" :max="3000" :step="10" v-model="sys.config.windMax" />
</template>
<NumSlider label="方向" :min="-360" :max="360" :step="1" v-model="sys.config.windDirection" />
<div class="gravity-curve-title"><span>生命周期内风力</span><span>纵轴:强度 0~1</span></div>
<CurveEditor v-model="sys.config.windCurve" :default-value="FLAT_CURVE" />
<div class="gravity-curve-axis"><span>0</span><span>生命周期 →</span><span>1</span></div>
<div class="modifier-hint">0° 向右 · -90° 向下 · 负强度会反转方向</div>
</div>
</div>
<div class="modifier-item">
<label class="switch-row modifier-switch">
<input type="checkbox" v-model="sys.config.forceField" />
<span class="switch-ui"></span><span>开启力场</span>
</label>
<div v-if="sys.config.forceField" class="modifier-params">
<NumSlider label="中心 X" :min="-5000" :max="5000" :step="1" v-model="sys.config.forceCenterX" />
<NumSlider label="中心 Y" :min="-5000" :max="5000" :step="1" v-model="sys.config.forceCenterY" />
<NumSlider label="力场强度" :min="-300" :max="300" :step="1" v-model="sys.config.forceStrength" />
<NumSlider label="力场半径" :min="1" :max="2000" :step="1" v-model="sys.config.forceRadius" />
<label class="row"><span>衰减模式</span>
<select v-model="sys.config.forceFalloff" class="inp">
<option value="constant">恒定(范围内力度不变)</option>
<option value="linear">线性衰减(边缘为 0)</option>
<option value="inverse">距离反比(中心最强)</option>
</select>
</label>
<div class="modifier-hint">正数吸向中心 · 负数向外排斥 · 可在画布内拖动力场圆</div>
</div>
</div>
<div class="modifier-item">
<label class="switch-row modifier-switch">
<input type="checkbox" v-model="sys.config.attraction" />
<span class="switch-ui"></span><span>开启吸附</span>
</label>
<div v-if="sys.config.attraction" class="modifier-params">
<NumSlider label="吸附点 X" :min="-5000" :max="5000" :step="1" v-model="sys.config.attractionCenterX" />
<NumSlider label="吸附点 Y" :min="-5000" :max="5000" :step="1" v-model="sys.config.attractionCenterY" />
<NumSlider label="吸附半径" :min="0" :max="2000" :step="1" v-model="sys.config.attractionRadius" />
<NumSlider label="吸附时长" :min="0.05" :max="30" :step="0.05" v-model="sys.config.attractionDuration" />
<NumSlider label="延迟时间" :min="0" :max="30" :step="0.05" v-model="sys.config.attractionDelay" />
<label class="row"><span>延迟方式</span>
<select v-model="sys.config.attractionDelayMode" class="inp">
<option value="global">统一时间</option>
<option value="particle">单粒子生命</option>
</select>
</label>
<label class="row"><span>吸附方式</span>
<select v-model="sys.config.attractionPath" class="inp">
<option value="linear">直线</option>
<option value="curve">曲线</option>
</select>
</label>
<div class="modifier-hint">延迟后从当前位置开始吸附 · 到达红色范围即死亡 · 可在画布内拖动吸附圆</div>
</div>
</div>
<div class="modifier-item">
<label class="switch-row modifier-switch">
<input type="checkbox" v-model="sys.config.trail" />
<span class="switch-ui"></span><span>开启拖尾</span>
</label>
<div v-if="sys.config.trail" class="modifier-params trail-params">
<div class="trail-section-head">
<div class="trail-section-title">拖尾独立图片</div>
<button class="image-add" title="增加拖尾图片资源" @click="addTrailResource">+</button>
</div>
<ImageResourceCard
v-for="resource in sys.config.trailResources"
:key="resource.id"
:resource="resource"
:resource-count="sys.config.trailResources.length"
default-file="trail.png"
name-placeholder="trail"
:show-folder="false"
@pick="openTrailTexturePicker"
@reset="resetTrailTexture"
@remove="removeTrailResource"
@update-weight="setTrailResourceWeight"
@toggle-lock="toggleTrailResourceLock"
>
<div class="resource-divider"></div>
<NumSlider label="旋转" :min="-360" :max="360" :step="1" :model-value="resource.rotation" @update:model-value="onTrailResourceRotationChange(resource.id, $event)" />
<NumSlider label="拖尾骨骼数" :min="2" :max="16" :step="1" :model-value="resource.boneCount" @update:model-value="onTrailResourceBoneCountChange(resource.id, $event)" />
<NumSlider label="拖尾比例 (%)" :min="0" :max="100" :step="1" v-model="resource.trailRatio" />
<label class="row"><span>拖尾长度</span>
<select v-model="resource.lengthMode" class="inp">
<option value="fixed">固定</option>
<option value="random">随机范围</option>
</select>
</label>
<NumSlider v-if="resource.lengthMode === 'fixed'" label="长度比例 (%)" :min="0" :max="500" :step="1" v-model="resource.length" />
<template v-else>
<NumSlider label="长度最小 (%)" :min="0" :max="500" :step="1" v-model="resource.lengthMin" />
<NumSlider label="长度最大 (%)" :min="0" :max="500" :step="1" v-model="resource.lengthMax" />
</template>
<label class="row"><span>拖尾宽度</span>
<select v-model="resource.widthSizeMode" class="inp">
<option value="fixed">固定</option>
<option value="random">随机范围</option>
</select>
</label>
<NumSlider v-if="resource.widthSizeMode === 'fixed'" label="宽度比例 (%)" :min="0" :max="500" :step="1" v-model="resource.width" />
<template v-else>
<NumSlider label="宽度最小 (%)" :min="0" :max="500" :step="1" v-model="resource.widthMin" />
<NumSlider label="宽度最大 (%)" :min="0" :max="500" :step="1" v-model="resource.widthMax" />
</template>
<label class="row"><span>宽度缩放</span>
<select v-model="resource.widthMode" class="inp">
<option value="particle">跟随粒子</option>
<option value="fixed">固定</option>
</select>
</label>
<label class="switch-row trail-sub-switch">
<input type="checkbox" v-model="resource.lifeLengthEnabled" />
<span class="switch-ui"></span><span>生命周期长度曲线</span>
</label>
<CurveEditor v-if="resource.lifeLengthEnabled" v-model="resource.lifeLengthCurve" :default-value="FLAT_CURVE" />
<label class="switch-row trail-sub-switch">
<input type="checkbox" v-model="resource.shapeEnabled" />
<span class="switch-ui"></span><span>形状曲线(首端 → 尾端宽度)</span>
</label>
<CurveEditor v-if="resource.shapeEnabled" v-model="resource.shapeCurve" :default-value="TRAIL_SHAPE_CURVE" />
<div class="trail-mode-head"><span>颜色模式</span>
<div class="alpha-modes">
<button class="axis-mode" :class="{ on: resource.colorMode === 'particle' }" @click="resource.colorMode = 'particle'">跟随粒子</button>
<button class="axis-mode" :class="{ on: resource.colorMode === 'fixed' }" @click="resource.colorMode = 'fixed'">固定</button>
<button class="axis-mode" :class="{ on: resource.colorMode === 'curve' }" @click="resource.colorMode = 'curve'">曲线</button>
</div>
</div>
<label class="row"><span>混合模式</span>
<select v-model="resource.blend" class="inp">
<option value="normal">正常</option>
<option value="add">相加</option>
<option value="multiply">相乘</option>
<option value="screen">滤色</option>
</select>
</label>
<label v-if="resource.colorMode === 'fixed'" class="row trail-fixed-color"><span>固定颜色</span>
<input class="colr" type="color" v-model="resource.color" />
<input class="hex-input" :value="resource.color" @change="setTrailColor(resource, $event)" @keydown.enter.prevent="commitTrailColor(resource, $event)" />
</label>
<ColorGradientEditor v-else-if="resource.colorMode === 'curve'" v-model="resource.colorGradient" />
<div class="trail-mode-head"><span>透明度模式</span>
<div class="alpha-modes">
<button class="axis-mode" :class="{ on: resource.alphaMode === 'particle' }" @click="resource.alphaMode = 'particle'">跟随粒子</button>
<button class="axis-mode" :class="{ on: resource.alphaMode === 'fixed' }" @click="resource.alphaMode = 'fixed'">固定</button>
<button class="axis-mode" :class="{ on: resource.alphaMode === 'curve' }" @click="resource.alphaMode = 'curve'">曲线</button>
</div>
</div>
<NumSlider v-if="resource.alphaMode === 'fixed'" label="透明度" :min="0" :max="1" :step="0.01" v-model="resource.alpha" />
<CurveEditor v-if="resource.alphaMode === 'curve'" v-model="resource.alphaCurve" :default-value="TRAIL_ALPHA_CURVE" />
</ImageResourceCard>
<input ref="trailTextureFileInput" class="file-input" type="file" accept="image/png,image/jpeg,image/webp" @change="onTrailTextureFile" />
<div class="trail-grid-row">
<NumSlider label="网格行数" :min="2" :max="16" :step="1" :model-value="sys.config.trailGridRows" @update:model-value="onTrailRigChange('trailGridRows', $event)" />
<NumSlider label="网格列数" :min="2" :max="32" :step="1" :model-value="sys.config.trailGridCols" @update:model-value="onTrailRigChange('trailGridCols', $event)" />
</div>
<div class="modifier-hint">每个粒子按资源比例生成一组同级拖尾骨骼;随机长度与宽度在粒子出生时确定,相同种子可稳定复现</div>
</div>
</div>
<div class="modifier-item">
<label class="switch-row modifier-switch">
<input type="checkbox" v-model="sys.config.emitterFollow" :disabled="sys.config.streamBehavior === 'loop'" />
<span class="switch-ui"></span><span>开启发射器跟随</span>
</label>
<div v-if="sys.config.emitterFollow && sys.config.streamBehavior !== 'loop'" class="modifier-params emitter-follow-params">
<label class="field-block"><span>跟随模式</span><select :value="sys.config.emitterFollowMode" class="inp" @change="onEmitterFollowModeChange($event, sys.config)">
<option value="none">无</option>
<option value="path">跟随路径</option>
<option v-if="selectedSpineBone || sys.config.emitterFollowMode === 'bone'" value="bone">选中的骨骼</option>
</select></label>
<template v-if="sys.config.emitterFollowMode === 'path'">
<label class="field-block"><span>跟随路径</span><select v-model.number="sys.config.emitterFollowPathId" class="inp">
<option :value="0">无</option>
<option v-for="path in store.paths" :key="path.id" :value="path.id">{{ path.name }}</option>
</select></label>
<label class="field-block"><span>发射器角度模式</span><select v-model="sys.config.emitterAngleMode" class="inp">
<option value="fixed">固定角度</option>
<option value="direction">跟随移动方向</option>
</select></label>
<div class="gravity-curve-title"><span>移动进度曲线 (0→1)</span></div>
<CurveEditor v-model="sys.config.emitterFollowCurve" :default-value="LINEAR_CURVE" />
<div class="field-grid follow-grid">
<label class="compact-field"><span>移动时长 (s)</span><input v-model.number="sys.config.emitterFollowDuration" type="number" min="0.05" step="0.05" class="inp" /></label>
<label class="compact-field"><span>跟随空间</span><select v-model="sys.config.emitterFollowSpace" class="inp">
<option value="local">局部空间(GEN 移动)</option>
<option value="world">世界空间(粒子生成点移动)</option>
</select></label>
</div>
<label class="field-block"><span>移动方向</span><select v-model="sys.config.emitterFollowDirection" class="inp">
<option value="forward">正向(起点 → 终点)</option>
<option value="reverse">反向(终点 → 起点)</option>
</select></label>
<NumSlider label="偏移起始点 (0-1)" :min="0" :max="1" :step="0.01" v-model="sys.config.emitterFollowOffset" />
<div class="modifier-hint">局部空间会移动整个发射器;世界空间只移动新粒子的生成点,已出生粒子留在世界位置。</div>
</template>
<template v-else-if="sys.config.emitterFollowMode === 'bone'">
<div v-if="boundSpineBone" class="bone-follow-target">跟随目标:<strong>{{ boundSpineBone.spine.name }} / {{ boundSpineBone.boneName }}</strong></div>
<div v-else class="bone-follow-missing">尚未绑定有效骨骼,请先在 Spine 层级树中选择骨骼。</div>
<button
v-if="selectedSpineBone && (!boundSpineBone || selectedSpineBone.spine.id !== boundSpineBone.spine.id || selectedSpineBone.boneName !== boundSpineBone.boneName)"
class="bone-follow-rebind"
@click="bindSelectedBone(sys.config)"
>{{ boundSpineBone ? '重新绑定' : '绑定' }}当前选中骨骼:{{ selectedSpineBone.spine.name }} / {{ selectedSpineBone.boneName }}</button>
<template v-if="boundSpineBone">
<label class="field-block"><span>跟随空间</span><select v-model="sys.config.emitterFollowSpace" class="inp">
<option value="local">本地空间(随骨骼移动)</option>
<option value="world">世界空间(粒子生成后保持位置)</option>
</select></label>
<div class="field-grid follow-grid">
<label class="compact-field"><span>位移偏移 X</span><input v-model.number="sys.config.emitterFollowBoneOffsetX" type="number" step="1" class="inp" /></label>
<label class="compact-field"><span>位移偏移 Y</span><input v-model.number="sys.config.emitterFollowBoneOffsetY" type="number" step="1" class="inp" /></label>
</div>
<div class="bone-follow-transform-grid">
<label class="compact-field"><span>旋转偏移</span><input v-model.number="sys.config.emitterFollowBoneRotation" type="number" step="1" class="inp" /></label>
<label class="compact-field"><span>缩放偏移 X</span><input v-model.number="sys.config.emitterFollowBoneScaleX" type="number" step="0.05" class="inp" /></label>
<label class="compact-field"><span>缩放偏移 Y</span><input v-model.number="sys.config.emitterFollowBoneScaleY" type="number" step="0.05" class="inp" /></label>
</div>
<div class="modifier-hint">发射器只跟随所选 Spine 骨骼的位置,不继承 Spine 的旋转或缩放;粒子变换使用粒子系统自身参数和上方偏移。</div>
</template>
</template>
</div>
</div>
<div class="modifier-item">
<label class="switch-row modifier-switch">
<input type="checkbox" v-model="sys.config.collision" />
<span class="switch-ui"></span><span>开启碰撞</span>
</label>
<div v-if="sys.config.collision" class="modifier-params collision-filter-params">
<div class="collision-filter-title">参与的碰撞对象</div>
<div class="collision-filter-tags">
<span
v-for="collider in selectedCollisionBodies"
:key="collider.id"
class="collision-filter-tag"
:class="{ disabled: !collider.enabled }"
:title="collider.enabled ? collider.name : `${collider.name}(碰撞体已禁用)`"
>
<span>{{ collider.name }}</span>
<button type="button" :aria-label="`清除碰撞对象 ${collider.name}`" @click="removeCollisionBody(collider.id)">×</button>
</span>
<button
type="button"
class="collision-filter-add"
:class="{ active: collisionPickerOpen }"
:disabled="availableCollisionBodies.length === 0"
:title="availableCollisionBodies.length ? '加入场景碰撞对象' : '没有可加入的碰撞对象'"
@click="collisionPickerOpen = !collisionPickerOpen"
>+</button>
</div>
<div v-if="selectedCollisionBodies.length === 0" class="collision-filter-empty">未选择碰撞对象,当前粒子不会发生碰撞</div>
<div v-if="collisionPickerOpen && availableCollisionBodies.length" class="collision-filter-picker">
<button
v-for="collider in availableCollisionBodies"
:key="collider.id"
type="button"
@click="addCollisionBody(collider.id)"
>
<span>{{ collider.name }}</span>
<small>{{ collider.enabled ? '可用' : '已禁用' }}</small>
</button>
</div>
<div class="modifier-hint">只有已加入且启用的场景碰撞对象会参与当前粒子系统的碰撞</div>
</div>
</div>
<NumSlider label="阻尼" :min="0" :max="2" :step="0.05" v-model="sys.config.damping" />
</div>
</div>
</template>
<script setup lang="ts">
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 { useParticlePanelContext } from './particlePanelContext'
const { store, sys, trailTextureFileInput, addTrailResource, openTrailTexturePicker, onTrailRigChange, onTrailResourceBoneCountChange, onTrailResourceRotationChange, onTrailTextureFile, resetTrailTexture, removeTrailResource, setTrailResourceWeight, toggleTrailResourceLock, setTrailColor, commitTrailColor, selectedSpineBone, boundSpineBone, collisionPickerOpen, selectedCollisionBodies, availableCollisionBodies, addCollisionBody, removeCollisionBody, collapsed, toggle, onEmitterFollowModeChange, bindSelectedBone, resetModule, select, LINEAR_CURVE, FLAT_CURVE, TRAIL_ALPHA_CURVE, TRAIL_SHAPE_CURVE } = useParticlePanelContext()
</script>
<style scoped>
.empty { color: #667; font-size: 12px; padding: 8px 0; }
.group { border: 1px solid #2a2a3c; border-radius: 8px; margin-bottom: 10px; overflow: hidden; }
.group-head {
display: flex; align-items: center; gap: 6px; padding: 7px 10px; cursor: pointer;
font-size: 12px; font-weight: 600; color: #ccd; background: #191924; user-select: none;
}
.group-head:hover { background: #20202e; }
.group-reset {
margin-left: auto; height: 22px; padding: 0 7px; border: 1px solid #3a465f; border-radius: 5px;
background: #252d3d; color: #9eabc3; font-size: 10px; line-height: 20px; cursor: pointer;
}
.group-reset:hover { border-color: #7774ff; background: #45426f; color: #fff; }
.group-body { padding: 8px 10px 12px; }
.caret { display: inline-block; width: 12px; color: #667; font-size: 11px; }
.divider { height: 1px; background: #2a2a3c; margin: 8px 0; }
.field-block { display: flex; flex-direction: column; gap: 5px; margin: 10px 0; color: #8f9ab3; font-size: 11px; }
.field-block > .inp { width: 100%; box-sizing: border-box; min-height: 30px; }
.field-block small { color: #5f6d86; line-height: 1.5; }
.field-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px; align-items: end; }
.compact-field { display: flex; min-width: 0; flex-direction: column; gap: 5px; margin: 5px 0; color: #8f9ab3; font-size: 11px; }
.compact-field .inp { width: 100%; min-height: 30px; box-sizing: border-box; }
.tag { font-size: 10px; color: #667; background: #1c1c2a; border-radius: 4px; padding: 1px 5px; }
.row { display: flex; align-items: center; gap: 8px; margin: 6px 0; font-size: 12px; color: #aab; }
.row > span:first-child { width: 60px; flex-shrink: 0; color: #8a93bb; }
.inp { flex: 1; min-width: 0; padding: 3px 5px; background: #1a1a28; border: 1px solid #2e2e44; border-radius: 5px; color: #dde; font-size: 12px; }
.colr { width: 28px; height: 22px; border: none; background: none; cursor: pointer; }
.placeholder { color: #667; font-size: 11px; padding: 6px 0; }
.image-add { width: 26px; height: 24px; border: 1px solid #4a4d9a; border-radius: 5px; background: #252850; color: #8f8cff; font-size: 18px; line-height: 1; cursor: pointer; }
.image-add:hover { background: #4542a8; color: #fff; }
.resource-divider { height: 1px; margin: 9px 0 7px; background: #2a3549; }
.hex-input { width: 72px; padding: 4px 6px; border: 1px solid #344158; border-radius: 5px; outline: none; background: #101725; color: #aeb8cb; font-size: 11px; font-family: monospace; }
.hex-input:focus { border-color: #7774ff; color: #fff; }
.alpha-modes { display: flex; gap: 3px; }
.alpha-modes button { padding: 4px 8px; border: 1px solid transparent; border-radius: 5px; background: transparent; color: #8d98ae; font-size: 11px; cursor: pointer; }
.alpha-modes button:hover { background: #273246; }
.alpha-modes button.on { border-color: #7774ff; background: #605df0; color: #fff; }
.file-input { display: none; }
.hex { font-size: 11px; color: #889; margin-left: auto; font-variant-numeric: tabular-nums; }
.inp.tiny { width: 46px; flex: 0 0 46px; text-align: center; }
.axis-mode { padding: 4px 8px; border: 1px solid #344158; border-radius: 5px; background: #273246; color: #aeb8cf; font-size: 11px; cursor: pointer; }
.axis-mode.on { border-color: #7774ff; background: #605df0; color: #fff; }
.switch-row { display: flex; align-items: center; gap: 9px; color: #b8c1d5; font-size: 12px; cursor: pointer; }
.switch-row input { position: absolute; opacity: 0; pointer-events: none; }
.switch-ui { position: relative; width: 32px; height: 18px; border-radius: 10px; background: #354258; transition: background .16s ease; }
.switch-ui::after { content: ''; position: absolute; left: 2px; top: 2px; width: 14px; height: 14px; border-radius: 50%; background: #fff; transition: transform .16s ease; }
.switch-row input:checked + .switch-ui { background: #625ff1; }
.switch-row input:checked + .switch-ui::after { transform: translateX(14px); }
.modifier-item { padding: 8px 0 10px; border-bottom: 1px solid #2a3549; }
.modifier-switch { font-weight: 600; }
.modifier-params { margin: 8px 0 0 10px; padding: 2px 0 2px 10px; border-left: 2px solid #344158; }
.modifier-hint { margin: 7px 0 0 2px; color: #647086; font-size: 9px; line-height: 1.5; }
.collision-filter-params { position: relative; padding-top: 5px; padding-bottom: 5px; }
.collision-filter-title { margin-bottom: 8px; color: #9ba7bd; font-size: 11px; font-weight: 600; }
.collision-filter-tags { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.collision-filter-tag { display: inline-flex; min-width: 0; align-items: center; gap: 6px; padding: 5px 5px 5px 8px; border: 1px solid #625ff1; border-radius: 6px; background: #514dcf; color: #fff; font-size: 11px; }
.collision-filter-tag > span { max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.collision-filter-tag.disabled { border-color: #4b5364; background: #343b4b; color: #929bad; }
.collision-filter-tag button { width: 18px; height: 18px; padding: 0; border: 0; border-radius: 4px; background: rgba(11, 15, 26, 0.22); color: inherit; line-height: 16px; cursor: pointer; }
.collision-filter-tag button:hover { background: rgba(11, 15, 26, 0.5); color: #fff; }
.collision-filter-add { width: 30px; height: 30px; padding: 0; border: 1px dashed #536079; border-radius: 6px; background: #182131; color: #98a5bd; font-size: 18px; line-height: 28px; cursor: pointer; }
.collision-filter-add:hover, .collision-filter-add.active { border-color: #7774ff; background: #262c52; color: #fff; }
.collision-filter-add:disabled { opacity: 0.42; cursor: not-allowed; }
.collision-filter-empty { margin-top: 7px; color: #6f7b91; font-size: 10px; }
.collision-filter-picker { display: grid; gap: 4px; margin-top: 7px; padding: 5px; border: 1px solid #344158; border-radius: 6px; background: #101725; }
.collision-filter-picker button { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 8px; border: 0; border-radius: 4px; background: transparent; color: #c7d0e3; font-size: 11px; text-align: left; cursor: pointer; }
.collision-filter-picker button:hover { background: #29344a; color: #fff; }
.collision-filter-picker button span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.collision-filter-picker button small { flex: none; color: #68758d; font-size: 9px; }
.switch-row input:disabled + .switch-ui { opacity: 0.45; cursor: not-allowed; }
.bone-follow-target { margin: 7px 0 9px; padding: 7px 8px; overflow: hidden; color: #7f8ba3; background: #121a29; border: 1px solid #2f3c53; border-radius: 5px; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
.bone-follow-target strong { color: #c8d0e4; font-weight: 600; }
.bone-follow-missing { margin: 7px 0 9px; padding: 7px 8px; color: #d49aa3; background: #2a1e26; border: 1px solid #513540; border-radius: 5px; font-size: 10px; line-height: 1.5; }
.bone-follow-rebind { width: 100%; min-height: 28px; margin: 0 0 8px; padding: 4px 8px; overflow: hidden; border: 1px solid #4a436e; border-radius: 5px; background: #2d2947; color: #c8c2ee; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }
.bone-follow-rebind:hover { border-color: #7165bd; background: #3a335d; color: #fff; }
.bone-follow-transform-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; }
.modifier-mode-row { display: flex; justify-content: flex-end; gap: 4px; margin-bottom: 6px; }
.gravity-curve-title { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 10px 0 6px; color: #9ba7bd; font-size: 11px; }
.gravity-curve-title span:last-child { color: #657188; font-size: 9px; }
.gravity-curve-axis { display: flex; justify-content: space-between; margin: 3px 34px 0 1px; color: #5f6b82; font-size: 9px; }
.gravity-params :deep(.ce-svg) { height: 96px; background: #0f1625; border-color: #30405a; }
.trail-params { padding-right: 2px; }
.trail-section-title { margin: 3px 0 8px; color: #aeb8cf; font-size: 12px; font-weight: 700; }
.trail-fixed-color .hex-input { min-width: 0; flex: 1; }
.trail-mode-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 10px 0 6px; color: #9ba7bd; font-size: 11px; }
.trail-sub-switch { margin: 11px 0 7px; padding-top: 9px; border-top: 1px solid #2a3549; }
.trail-grid-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 8px; }
.trail-grid-row :deep(.ns) { min-width: 0; flex-wrap: wrap; align-items: center; }
.trail-grid-row :deep(.ns-label) { width: 100%; }
.trail-grid-row :deep(.ns-range) { min-width: 0; }
.trail-grid-row :deep(.ns-num) { width: 42px; }
.trail-params :deep(.ce-svg) { height: 92px; background: #0f1625; border-color: #30405a; }
</style>
@@ -0,0 +1,53 @@
<template>
<div class="group config-management">
<div class="group-head" @click="toggle('config')">
<span class="caret">{{ collapsed.config ? '▸' : '▾' }}</span><span>配置管理</span>
</div>
<div class="group-body config-management-body" v-show="!collapsed.config">
<div class="config-toolbar">
<button title="保存全部编辑器配置 (Ctrl+S)" @click="saveConfig">保存配置</button>
<button title="加载本地编辑器配置" @click="configFileInput?.click()">加载配置</button>
<select v-model="selectedPreset" aria-label="预设列表" :disabled="!presetOptions.length || presetLoading" @pointerdown="refreshPresets" @focus="refreshPresets" @change="loadSelectedPreset">
<option value="">{{ presetOptions.length ? '选择预设' : '无预设文件' }}</option>
<option v-for="preset in presetOptions" :key="preset.file" :value="preset.file">{{ preset.name }}</option>
</select>
<input ref="configFileInput" hidden type="file" accept=".json,application/json" @change="onConfigFileChange" />
</div>
<div v-if="configMessage" class="config-message" :class="{ error: configError }">{{ configMessage }}</div>
</div>
</div>
</template>
<script setup lang="ts">
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 { useParticlePanelContext } from './particlePanelContext'
const { configFileInput, selectedPreset, presetOptions, presetLoading, configMessage, configError, saveConfig, onConfigFileChange, refreshPresets, loadSelectedPreset, collapsed, toggle, select } = useParticlePanelContext()
</script>
<style scoped>
.config-management-body { padding-bottom: 2px; }
.config-toolbar { display: grid; grid-template-columns: auto auto minmax(0,1fr); gap: 6px; margin-bottom: 10px; }
.config-toolbar button,.config-toolbar select {
min-width: 0; height: 30px; box-sizing: border-box; border: 1px solid #3b4864; border-radius: 6px;
background: #263149; color: #d6def0; font-size: 11px;
}
.config-toolbar button { padding: 0 9px; cursor: pointer; }
.config-toolbar button:hover { border-color: #7169dc; background: #343d68; color: #fff; }
.config-toolbar select { padding: 0 7px; overflow: hidden; text-overflow: ellipsis; }
.config-toolbar select:disabled { color: #68738a; background: #1b2130; }
.config-message { margin: -4px 0 9px; color: #83b9a2; font-size: 10px; }
.config-message.error { color: #e8959e; }
.group { border: 1px solid #2a2a3c; border-radius: 8px; margin-bottom: 10px; overflow: hidden; }
.group-head {
display: flex; align-items: center; gap: 6px; padding: 7px 10px; cursor: pointer;
font-size: 12px; font-weight: 600; color: #ccd; background: #191924; user-select: none;
}
.group-head:hover { background: #20202e; }
.group-body { padding: 8px 10px 12px; }
.caret { display: inline-block; width: 12px; color: #667; font-size: 11px; }
</style>
@@ -0,0 +1,275 @@
<template>
<!-- 1. 场景对象:创建入口始终可用,即使当前没有粒子系统。 -->
<div class="group">
<div class="group-head scene-head" @click="toggle('scene')">
<span class="caret">{{ collapsed.scene ? '▸' : '▾' }}</span><span>场景对象</span>
<div class="scene-actions">
<button class="scene-add" title="添加粒子系统" @click.stop="addSys">+粒子</button>
<button class="scene-add" title="添加碰撞体" @click.stop="addCollider">+碰撞</button>
<button class="scene-add" title="添加路径" @click.stop="addPath">+路径</button>
<button class="scene-add" title="添加 Spine" @click.stop="addSpine">+Spine</button>
<button class="scene-add" :disabled="!canCopyObject" title="复制当前选中的场景对象" @click.stop="copySelectedObject">+Copy</button>
</div>
</div>
<div class="group-body" v-show="!collapsed.scene">
<div class="tree scene-tree">
<div
v-for="(system, i) in store.systems"
:key="`particle-${system.id}`"
class="tree-item scene-object-item"
:class="{ on: activeObjectType === 'particle' && system.id === store.activeObjectId }"
@click="select(i)"
>
<span class="tdot">●</span>
<span class="tlabel">{{ system.config.name || 'ParticleSystem' + (i + 1) }}</span>
<button class="scene-object-eye" :class="{ off: system.visible === false }" :title="system.visible === false ? '显示粒子系统' : '隐藏粒子系统'" :aria-label="system.visible === false ? '显示粒子系统' : '隐藏粒子系统'" @click.stop="system.visible = system.visible === false">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"/><circle cx="12" cy="12" r="2.8"/><path v-if="system.visible === false" class="eye-slash" d="M4 4l16 16"/></svg>
</button>
<span class="scene-object-kind">粒子</span>
<button class="scene-object-delete" title="删除粒子系统" @click.stop="store.removeSystem(system.id)">✕</button>
</div>
<div
v-for="collider in store.colliders"
:key="`collision-${collider.id}`"
class="tree-item scene-object-item"
:class="{ on: activeObjectType === 'collision' && collider.id === store.activeObjectId }"
@click="store.selectSceneObject('collision', collider.id)"
>
<span class="tdot collision-dot">■</span>
<span class="tlabel">{{ collider.name }}</span>
<button class="scene-object-eye" :class="{ off: collider.visible === false }" :title="collider.visible === false ? '显示碰撞体' : '隐藏碰撞体'" :aria-label="collider.visible === false ? '显示碰撞体' : '隐藏碰撞体'" @click.stop="collider.visible = collider.visible === false">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"/><circle cx="12" cy="12" r="2.8"/><path v-if="collider.visible === false" class="eye-slash" d="M4 4l16 16"/></svg>
</button>
<span class="scene-object-kind">碰撞</span>
<button class="scene-object-delete" title="删除碰撞体" @click.stop="store.removeCollider(collider.id)">✕</button>
</div>
<div
v-for="path in store.paths"
:key="`path-${path.id}`"
class="tree-item scene-object-item"
:class="{ on: activeObjectType === 'path' && path.id === store.activeObjectId }"
@click="store.selectSceneObject('path', path.id)"
>
<span class="tdot path-dot">◇</span>
<span class="tlabel">{{ path.name }}</span>
<button class="scene-object-eye" :class="{ off: path.visible === false }" :title="path.visible === false ? '显示路径' : '隐藏路径'" :aria-label="path.visible === false ? '显示路径' : '隐藏路径'" @click.stop="path.visible = path.visible === false">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"/><circle cx="12" cy="12" r="2.8"/><path v-if="path.visible === false" class="eye-slash" d="M4 4l16 16"/></svg>
</button>
<span class="scene-object-kind">路径</span>
<button class="scene-object-delete" title="删除路径" @click.stop="store.removePath(path.id)">✕</button>
</div>
<div
v-for="spine in store.spines"
:key="`spine-${spine.id}`"
class="tree-item scene-object-item"
:class="{ on: activeObjectType === 'spine' && spine.id === store.activeObjectId }"
@click="store.selectSceneObject('spine', spine.id)"
>
<span class="tdot spine-dot">◆</span>
<span class="tlabel">{{ spine.name }}</span>
<button class="scene-object-eye" :class="{ off: spine.visible === false }" :title="spine.visible === false ? '显示 Spine' : '隐藏 Spine'" :aria-label="spine.visible === false ? '显示 Spine' : '隐藏 Spine'" @click.stop="spine.visible = spine.visible === false">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"/><circle cx="12" cy="12" r="2.8"/><path v-if="spine.visible === false" class="eye-slash" d="M4 4l16 16"/></svg>
</button>
<span class="scene-object-kind">Spine</span>
<button class="scene-object-delete" title="删除 Spine" @click.stop="store.removeSpine(spine.id)">✕</button>
</div>
<div v-if="!store.systems.length && !store.colliders.length && !store.paths.length && !store.spines.length" class="empty">场景中暂无对象</div>
</div>
<template v-if="activeObjectType === 'particle' && sys">
<label class="row"><span>名称</span><input v-model="sys.config.name" class="inp" /></label>
<NumSlider label="位置X" :min="-2000" :max="2000" :step="1" v-model="sys.config.centerX" />
<NumSlider label="位置Y" :min="-2000" :max="2000" :step="1" v-model="sys.config.centerY" />
<NumSlider label="偏移X" :min="-2000" :max="2000" :step="1" v-model="sys.config.offsetX" />
<NumSlider label="偏移Y" :min="-2000" :max="2000" :step="1" v-model="sys.config.offsetY" />
<NumSlider label="旋转" :min="-360" :max="360" :step="1" v-model="sys.config.rootRotation" />
<NumSlider label="缩放X" :min="0.05" :max="10" :step="0.05" v-model="sys.config.rootScaleX" />
<NumSlider label="缩放Y" :min="0.05" :max="10" :step="0.05" v-model="sys.config.rootScaleY" />
<div class="seed-row scene-seed">
<label for="system-seed">随机种子</label>
<input id="system-seed" class="seed-input" type="number" min="0" step="1" v-model.number="sys.config.seed" title="控制粒子系统内全部随机属性、资源选择与修改器随机采样" />
<button class="seed-random" title="生成新的全局随机种子" @click="randomizeSeed">⚄</button>
</div>
<div class="seed-hint">控制初始随机参数、资源选择和修改器随机采样;相同种子可复现相同效果</div>
</template>
<template v-else-if="activeCollider">
<div class="object-editor-title">碰撞体属性</div>
<label class="field-block"><span>碰撞体名称</span><input v-model="activeCollider.name" class="inp" /></label>
<div class="field-grid">
<label class="field-block"><span>类型</span><select v-model="activeCollider.shape" class="inp">
<option value="circle">圆形</option><option value="ellipse">椭圆</option><option value="rect">矩形</option><option value="polygon">多边形</option><option value="custom">自定义</option>
</select></label>
<label class="field-block check-field"><span>启用</span><input v-model="activeCollider.enabled" type="checkbox" /></label>
</div>
<div class="field-grid">
<label class="compact-field"><span>X 位置</span><input v-model.number="activeCollider.x" type="number" class="inp" /></label>
<label class="compact-field"><span>Y 位置</span><input v-model.number="activeCollider.y" type="number" class="inp" /></label>
</div>
<NumSlider label="旋转" :min="-360" :max="360" :step="1" v-model="activeCollider.rotation" />
<div class="field-grid">
<label class="compact-field"><span>缩放 X</span><input v-model.number="activeCollider.scaleX" type="number" min="0.05" step="0.05" class="inp" /></label>
<label class="compact-field"><span>缩放 Y</span><input v-model.number="activeCollider.scaleY" type="number" min="0.05" step="0.05" class="inp" /></label>
</div>
<NumSlider v-if="activeCollider.shape === 'circle'" label="半径" :min="1" :max="1000" :step="1" v-model="activeCollider.radius" />
<div v-else-if="activeCollider.shape === 'ellipse'" class="field-grid">
<label class="compact-field"><span>X 半径</span><input v-model.number="activeCollider.radiusX" type="number" min="1" class="inp" /></label>
<label class="compact-field"><span>Y 半径</span><input v-model.number="activeCollider.radiusY" type="number" min="1" class="inp" /></label>
</div>
<div v-else class="field-grid">
<label class="compact-field"><span>宽度</span><input v-model.number="activeCollider.width" type="number" min="1" class="inp" /></label>
<label class="compact-field"><span>高度</span><input v-model.number="activeCollider.height" type="number" min="1" class="inp" /></label>
</div>
<NumSlider v-if="activeCollider.shape === 'polygon' || activeCollider.shape === 'custom'" label="边数" :min="3" :max="16" :step="1" v-model="activeCollider.sides" />
<label class="field-block"><span>碰撞响应</span><select v-model="activeCollider.response" class="inp">
<option value="physics">物理(弹跳/摩擦)</option><option value="pause">暂停(停止不动)</option><option value="destroy">消失(立即销毁)</option>
</select></label>
<div v-if="activeCollider.response === 'physics'" class="field-grid">
<label class="compact-field"><span>弹性 (0-1)</span><input v-model.number="activeCollider.elasticity" type="number" min="0" max="1" step="0.01" class="inp" /></label>
<label class="compact-field"><span>摩擦 (0-1)</span><input v-model.number="activeCollider.friction" type="number" min="0" max="1" step="0.01" class="inp" /></label>
</div>
<div class="collider-follow-section">
<label class="follow-path"><input v-model="activeCollider.followPath" type="checkbox" />跟随路径移动</label>
<template v-if="activeCollider.followPath">
<label class="field-block"><span>目标路径</span><select v-model.number="activeCollider.followPathId" class="inp">
<option :value="0">(未选择)</option>
<option v-for="path in store.paths" :key="path.id" :value="path.id">{{ path.name }}</option>
</select></label>
<div class="field-grid">
<label class="compact-field"><span>移动时长(秒)</span><input v-model.number="activeCollider.followDuration" type="number" min="0.05" step="0.05" class="inp" /></label>
<label class="compact-field"><span>曲线模式</span><select v-model="activeCollider.followCurveMode" class="inp">
<option>跟随导出</option><option>线性</option><option>贝塞尔</option>
</select></label>
</div>
<label class="follow-path"><input v-model="activeCollider.followPathRotation" type="checkbox" />方向沿路径</label>
<div class="collider-curve-title">移动进度曲线 <small>横轴:实时/移动时长 纵轴:路径进度(0=起点 → 1=终点)</small></div>
<CurveEditor
:model-value="activeCollider.followCurve || LINEAR_CURVE"
:default-value="LINEAR_CURVE"
:mode="resolvedCurveEditorMode(activeCollider.followCurveMode)"
@update:model-value="activeCollider.followCurve = $event"
/>
</template>
</div>
</template>
<template v-else-if="activePath">
<div class="object-editor-title">路径属性</div>
<label class="field-block"><span>路径名称</span><input v-model="activePath.name" class="inp" /></label>
<div class="field-grid">
<label class="compact-field"><span>X 位置</span><input v-model.number="activePath.x" type="number" class="inp" /></label>
<label class="compact-field"><span>Y 位置</span><input v-model.number="activePath.y" type="number" class="inp" /></label>
</div>
<NumSlider label="旋转" :min="-360" :max="360" :step="1" v-model="activePath.rotation" />
<div class="field-grid">
<label class="compact-field"><span>缩放 X</span><input v-model.number="activePath.scaleX" type="number" min="0.05" step="0.05" class="inp" /></label>
<label class="compact-field"><span>缩放 Y</span><input v-model.number="activePath.scaleY" type="number" min="0.05" step="0.05" class="inp" /></label>
</div>
<label class="setting-line"><span>闭合路径</span><input v-model="activePath.closed" type="checkbox" /></label>
<label class="setting-line"><span>路径颜色</span><input v-model="activePath.color" type="color" class="path-color" /></label>
<div class="path-help"><span>操作说明:</span><ul>
<li>双击空白处/曲线:新建锚点</li><li>双击锚点:删除节点</li><li>双击第二下按住拖动:新建并拉出手柄</li>
<li>拖动锚点移动位置,拖动手柄调整曲线</li><li>Shift+拖动手柄:两侧完全对称</li><li>Alt+拖动手柄:独立控制单侧</li>
<li>Ctrl+点击/框选:加选节点</li><li>Alt+点击/框选:减选节点</li><li>Alt+单击空白处:清除全部选中</li><li>选中多个节点后可整体拖动</li>
</ul></div>
<label class="field-block"><span>应用预设形状</span><select v-model="activePath.preset" class="inp">
<option value="">选择预设...</option><option value="circle">圆形</option><option value="square">方形</option><option value="polygon">多边形</option><option value="star">星形</option>
</select></label>
<button class="path-action primary" @click="applyPathPreset">应用预设</button>
<button class="path-action" @click="clearPathAnchors">清空锚点</button>
</template>
<SpineObjectPanel v-else-if="activeSpine" :spine-object="activeSpine" />
<div v-else class="panel-hint">使用右上角按钮创建场景对象</div>
</div>
</div>
</template>
<script setup lang="ts">
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 { useParticlePanelContext } from './particlePanelContext'
const { store, resolvedCurveEditorMode, sys, activeObjectType, activeCollider, activePath, activeSpine, canCopyObject, collapsed, toggle, select, addSys, addCollider, addPath, addSpine, copySelectedObject, applyPathPreset, clearPathAnchors, randomizeSeed, LINEAR_CURVE } = useParticlePanelContext()
</script>
<style scoped>
.panel { display: flex; width: 100%; height: 100%; flex-direction: column; overflow: hidden; background: #14141f; border-left: 1px solid #2a2a3c; padding: 12px; box-sizing: border-box; }
.empty, .panel-hint { color: #667; font-size: 12px; padding: 8px 0; }
.group { border: 1px solid #2a2a3c; border-radius: 8px; margin-bottom: 10px; overflow: hidden; }
.group-head {
display: flex; align-items: center; gap: 6px; padding: 7px 10px; cursor: pointer;
font-size: 12px; font-weight: 600; color: #ccd; background: #191924; user-select: none;
}
.group-head:hover { background: #20202e; }
.scene-head { padding-right: 6px; }
.scene-actions { margin-left: auto; display: flex; align-items: center; gap: 4px; }
.scene-add {
height: 24px; padding: 0 6px; border: 1px solid #3a4a6a; border-radius: 5px;
background: #2e3a55; color: #cdf; font-size: 10px; white-space: nowrap; cursor: pointer;
}
.scene-add:hover { border-color: #7774ff; background: #45426f; color: #fff; }
.scene-add:disabled { opacity: 0.42; cursor: not-allowed; border-color: #343a4b; background: #252a38; color: #778095; }
.group-body { padding: 8px 10px 12px; }
.caret { display: inline-block; width: 12px; color: #667; font-size: 11px; }
.tree { margin-bottom: 8px; }
.tree-item { display: flex; align-items: center; gap: 6px; padding: 5px 6px; font-size: 12px; color: #bcd; border-radius: 6px; margin-bottom: 2px; }
.tree-item.on { background: #26324a; color: #ffe; }
.tree-item .tdot { color: #ffcc33; font-size: 10px; }
.tree-item .tdot.dim { color: #556; }
.tree-item .tdot.collision-dot { color: #ff7070; }
.tree-item .tdot.path-dot { color: #ff933e; font-size: 14px; }
.tree-item .tdot.spine-dot { color: #c08cff; font-size: 11px; }
.tree-item .tlabel { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.scene-tree { padding-bottom: 8px; border-bottom: 1px solid #2a2a3c; }
.scene-object-item { min-height: 28px; cursor: pointer; }
.scene-object-item:hover { background: #202b40; }
.scene-object-kind { color: #75829c; font-size: 10px; }
.scene-object-eye {
width: 24px; height: 22px; padding: 3px; flex: 0 0 24px; border: 1px solid transparent; border-radius: 5px;
background: transparent; color: #9aa6bf; cursor: pointer;
}
.scene-object-eye:hover { border-color: #4b5972; background: #29354a; color: #e1e7f4; }
.scene-object-eye.off { color: #59647a; }
.scene-object-eye svg { display: block; width: 100%; height: 100%; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.scene-object-eye .eye-slash { stroke: #d47b88; stroke-width: 2; }
.scene-object-delete {
width: 24px; height: 22px; padding: 0; border: 1px solid #59414a; border-radius: 5px;
background: #3a272f; color: #efb4bd; font-size: 11px; cursor: pointer;
}
.scene-object-delete:hover { border-color: #a45c69; background: #57303a; color: #fff; }
.object-editor-title { margin: 10px 0 8px; padding-top: 8px; border-top: 1px solid #2a2a3c; color: #cbd4e8; font-weight: 700; }
.field-block { display: flex; flex-direction: column; gap: 5px; margin: 10px 0; color: #8f9ab3; font-size: 11px; }
.field-block > .inp { width: 100%; box-sizing: border-box; min-height: 30px; }
.field-block small { color: #5f6d86; line-height: 1.5; }
.field-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px; align-items: end; }
.compact-field { display: flex; min-width: 0; flex-direction: column; gap: 5px; margin: 5px 0; color: #8f9ab3; font-size: 11px; }
.compact-field .inp { width: 100%; min-height: 30px; box-sizing: border-box; }
.check-field { align-items: flex-start; }
.check-field input { width: 18px; height: 18px; margin: 4px 0; accent-color: #4f7ee8; }
.follow-path, .setting-line { display: flex; align-items: center; gap: 8px; margin: 12px 0 4px; color: #aeb8cf; font-size: 12px; }
.setting-line { justify-content: space-between; }
.follow-path input, .setting-line input[type='checkbox'] { width: 17px; height: 17px; accent-color: #4f7ee8; }
.collider-follow-section { margin-top: 12px; padding-top: 4px; border-top: 1px solid #30394d; }
.collider-curve-title { display: flex; flex-direction: column; gap: 3px; margin: 12px 0 6px; color: #aeb8cf; font-size: 12px; }
.collider-curve-title small { color: #67738c; font-size: 10px; line-height: 1.45; }
.path-color { width: 48px; height: 28px; padding: 0; border: 1px solid #3a4963; background: transparent; cursor: pointer; }
.path-help { margin: 12px 0; color: #7d89a2; font-size: 11px; line-height: 1.55; }
.path-help > span { color: #aeb8cf; }
.path-help ul { margin: 6px 0 0; padding-left: 20px; }
.path-help li { margin: 3px 0; }
.path-action { width: 100%; height: 31px; margin-top: 7px; border: 0; border-radius: 5px; background: #33425b; color: #c9d1e1; cursor: pointer; }
.path-action:hover { filter: brightness(1.12); }
.path-action.primary { background: #f05408; color: #fff; }
.row { display: flex; align-items: center; gap: 8px; margin: 6px 0; font-size: 12px; color: #aab; }
.row > span:first-child { width: 60px; flex-shrink: 0; color: #8a93bb; }
.inp { flex: 1; min-width: 0; padding: 3px 5px; background: #1a1a28; border: 1px solid #2e2e44; border-radius: 5px; color: #dde; font-size: 12px; }
.inp.tiny { width: 46px; flex: 0 0 46px; text-align: center; }
.seed-row { display: flex; align-items: center; gap: 8px; margin-top: 12px; padding-top: 12px; border-top: 1px solid #2d3950; }
.seed-row label { width: 64px; color: #8f9bb5; font-size: 12px; }
.seed-input { min-width: 0; flex: 1; padding: 6px 8px; border: 1px solid #344158; border-radius: 6px; outline: none; background: #0f1625; color: #e2e7f2; font-size: 12px; }
.seed-input:focus { border-color: #625ff1; }
.seed-random { width: 34px; height: 30px; border: 1px solid #3a4860; border-radius: 6px; background: #2c3950; color: #d7def0; cursor: pointer; }
.seed-random:hover { background: #394964; }
.scene-seed { margin-top: 10px; }
.seed-hint { margin: 5px 0 1px 72px; color: #657188; font-size: 9px; line-height: 1.45; }
</style>
@@ -0,0 +1,17 @@
import { inject, provide, type InjectionKey } from 'vue'
import { useParticlePanel } from './useParticlePanel'
type ParticlePanelContext = ReturnType<typeof useParticlePanel>
const particlePanelKey: InjectionKey<ParticlePanelContext> = Symbol('particle-panel')
export function provideParticlePanel() {
const context = useParticlePanel()
provide(particlePanelKey, context)
return context
}
export function useParticlePanelContext() {
const context = inject(particlePanelKey)
if (!context) throw new Error('Particle panel sections require a ParticlePanel parent')
return context
}
@@ -0,0 +1,7 @@
export const LINEAR_CURVE = [{ x: 0, y: 0 }, { x: 1, y: 1 }]
export const FLAT_CURVE = [{ x: 0, y: 1 }, { x: 1, y: 1 }]
export const ALPHA_CURVE = [{ x: 0, y: 0 }, { x: 0.2, y: 1 }, { x: 0.8, y: 1 }, { x: 1, y: 0 }]
export const RESOURCE_ALPHA_CURVE = [{ x: 0, y: 0 }, { x: 0.1, y: 1 }, { x: 0.9, y: 1 }, { x: 1, y: 0 }]
export const TRAIL_ALPHA_CURVE = [{ x: 0, y: 1 }, { x: 1, y: 1 }]
export const TRAIL_SHAPE_CURVE = [{ x: 0, y: 1 }, { x: 1, y: 1 }]
@@ -0,0 +1,225 @@
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 }
}
@@ -0,0 +1,458 @@
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', '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',
'attraction', 'attractionCenterX', 'attractionCenterY', 'attractionRadius',
'attractionDuration', 'attractionDelay', 'attractionDelayMode', 'attractionPath',
'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 }
}
@@ -0,0 +1,433 @@
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 useParticleResources(sys: import('vue').ComputedRef<ReturnType<typeof useParticleStore>['systems'][number] | null>) {
const textureFileInput = ref<HTMLInputElement | null>(null)
const sequenceFileInput = ref<HTMLInputElement | null>(null)
const pendingResourceId = ref<number | null>(null)
const trailTextureFileInput = ref<HTMLInputElement | null>(null)
const pendingTrailResourceId = ref<number | null>(null)
function addTrailResource() {
const config = sys.value?.config
if (!config) return
const nextId = Math.max(0, ...config.trailResources.map((resource) => resource.id)) + 1
config.trailResources.push({
id: nextId,
texture: null,
textureName: 'trail',
previewUrl: '/images/trail.png',
weight: 100,
locked: false,
rotation: 0,
colorMode: 'particle',
color: '#ffffff',
colorGradient: [{ position: 0, color: '#ffffff' }, { position: 1, color: '#ffffff' }],
blend: 'normal',
alphaMode: 'particle',
alpha: 1,
alphaCurve: TRAIL_ALPHA_CURVE.map((point) => ({ ...point })),
boneCount: 3,
trailRatio: 100,
lengthMode: 'fixed',
length: 100,
lengthMin: 100,
lengthMax: 100,
widthSizeMode: 'fixed',
width: 100,
widthMin: 100,
widthMax: 100,
widthMode: 'particle',
lifeLengthEnabled: false,
lifeLengthCurve: FLAT_CURVE.map((point) => ({ ...point })),
shapeEnabled: false,
shapeCurve: TRAIL_SHAPE_CURVE.map((point) => ({ ...point })),
})
rebalanceTrailWeights()
}
function openTrailTexturePicker(resourceId: number) {
pendingTrailResourceId.value = resourceId
trailTextureFileInput.value?.click()
}
type TrailRigKey = 'trailGridRows' | 'trailGridCols'
function onTrailRigChange(key: TrailRigKey, value: number) {
const config = sys.value?.config
if (!config) return
const maximum = key === 'trailGridCols' ? 32 : 16
config[key] = Math.min(maximum, Math.max(2, Math.round(Number(value) || 2)))
// 只触发骨骼和蒙皮重建;所有正在使用的拖尾图片与粒子资源选择保持不变。
}
function onTrailResourceBoneCountChange(resourceId: number, value: number) {
const resource = sys.value?.config.trailResources.find((item) => item.id === resourceId)
if (!resource) return
resource.boneCount = Math.min(16, Math.max(2, Math.round(Number(value) || 2)))
// 骨骼数量包含在该资源的蒙皮签名中,运行层会保留图片并重新绑定。
}
function onTrailResourceRotationChange(resourceId: number, value: number) {
const resource = sys.value?.config.trailResources.find((item) => item.id === resourceId)
if (!resource) return
resource.rotation = Math.max(-360, Math.min(360, Number(value) || 0))
// 旋转属于图片默认方向;运行层将新的角度写入 UV 签名并重新绑定蒙皮。
}
async function onTrailTextureFile(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
const config = sys.value?.config
const resource = config?.trailResources.find((item) => item.id === pendingTrailResourceId.value)
if (!file || !resource) { input.value = ''; return }
const dataUrl = await readFileAsDataUrl(file)
resource.texture = markRaw(await Texture.fromURL(dataUrl)) as any
resource.previewUrl = dataUrl
resource.textureName = file.name.replace(/\.[^.]+$/, '') || 'trail'
input.value = ''
pendingTrailResourceId.value = null
}
async function resetTrailTexture(resourceId: number) {
const config = sys.value?.config
const resource = config?.trailResources.find((item) => item.id === resourceId)
if (!resource) return
resource.texture = markRaw(await Texture.fromURL('/images/trail.png')) as any
resource.previewUrl = '/images/trail.png'
resource.textureName = 'trail'
resource.weight = 100
resource.locked = false
resource.rotation = 0
resource.boneCount = 3
resource.trailRatio = 100
resource.lengthMode = 'fixed'
resource.length = 100
resource.lengthMin = 100
resource.lengthMax = 100
resource.widthSizeMode = 'fixed'
resource.width = 100
resource.widthMin = 100
resource.widthMax = 100
resource.widthMode = 'particle'
resource.lifeLengthEnabled = false
resource.lifeLengthCurve = FLAT_CURVE.map((point) => ({ ...point }))
resource.shapeEnabled = false
resource.shapeCurve = TRAIL_SHAPE_CURVE.map((point) => ({ ...point }))
resource.colorMode = 'particle'
resource.color = '#ffffff'
resource.colorGradient = [
{ position: 0, color: '#ffffff' },
{ position: 1, color: '#ffffff' },
]
resource.blend = 'normal'
resource.alphaMode = 'particle'
resource.alpha = 1
resource.alphaCurve = TRAIL_ALPHA_CURVE.map((point) => ({ ...point }))
rebalanceTrailWeights()
}
function removeTrailResource(resourceId: number) {
const resources = sys.value?.config.trailResources
if (!resources || resources.length <= 1) return
const index = resources.findIndex((resource) => resource.id === resourceId)
if (index >= 0) resources.splice(index, 1)
rebalanceTrailWeights()
}
function rebalanceTrailWeights() {
const resources = sys.value?.config.trailResources
if (!resources?.length) return
if (resources.length === 1) {
resources[0].weight = 100
resources[0].locked = false
return
}
const locked = resources.filter((resource) => resource.locked)
const unlocked = resources.filter((resource) => !resource.locked)
let lockedTotal = locked.reduce((sum, resource) => sum + Math.max(0, Number(resource.weight) || 0), 0)
if (lockedTotal > 100) {
distributeImageWeights(locked, 100, true)
lockedTotal = 100
}
distributeImageWeights(unlocked, 100 - lockedTotal)
}
function setTrailResourceWeight(resourceId: number, value: number) {
const resources = sys.value?.config.trailResources
if (!resources?.length) return
if (resources.length === 1) {
resources[0].weight = 100
resources[0].locked = false
return
}
const resource = resources.find((item) => item.id === resourceId)
if (!resource || resource.locked) return
const lockedTotal = resources
.filter((item) => item.id !== resourceId && item.locked)
.reduce((sum, item) => sum + Math.max(0, Number(item.weight) || 0), 0)
const available = Math.max(0, 100 - lockedTotal)
const others = resources.filter((item) => item.id !== resourceId && !item.locked)
resource.weight = roundWeight(Math.min(available, Math.max(0, Number(value) || 0)))
if (!others.length) resource.weight = roundWeight(available)
else distributeImageWeights(others, available - resource.weight, true)
}
function toggleTrailResourceLock(resourceId: number) {
const resources = sys.value?.config.trailResources
if (!resources || resources.length === 1) return
const resource = resources.find((item) => item.id === resourceId)
if (resource) resource.locked = !resource.locked
}
function setTrailColor(resource: { color: string }, event: Event) {
const input = event.target as HTMLInputElement
resource.color = normalizeHex(input.value, resource.color)
input.value = resource.color
}
function commitTrailColor(resource: { color: string }, event: KeyboardEvent) {
setTrailColor(resource, event)
;(event.target as HTMLInputElement).blur()
}
function addImageResource() {
const config = sys.value?.config
if (!config) return
const nextId = Math.max(0, ...config.imageResources.map((resource) => resource.id)) + 1
config.imageResources.push({
id: nextId,
texture: config.texture,
textureName: 'star',
textureFolder: '',
previewUrl: '/images/star.png',
imageMode: 'fixed',
sequencePlayback: 'loop-forward',
sequenceFps: 30,
sequenceFrames: [],
weight: 100,
locked: false,
scale: 1,
anchorX: 0.5,
anchorY: 0.5,
colorMode: 'fixed',
colorStart: '#ffffff',
colorEnd: '#ff4d4d',
colorGradient: [{ position: 0, color: '#ffffff' }, { position: 1, color: '#ff4d4d' }],
blend: 'normal',
independentAlpha: 'off',
alpha: 1,
alphaCurveMode: '跟随导出',
alphaCurve: RESOURCE_ALPHA_CURVE.map((point) => ({ ...point })),
} as any)
rebalanceImageWeights()
}
function openTexturePicker(resourceId: number) {
pendingResourceId.value = resourceId
textureFileInput.value?.click()
}
function openSequencePicker(resourceId: number) {
pendingResourceId.value = resourceId
sequenceFileInput.value?.click()
}
async function onTextureFile(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
const config = sys.value?.config
const resource = config?.imageResources.find((item) => item.id === pendingResourceId.value)
if (!file || !resource) { input.value = ''; return }
const dataUrl = await readFileAsDataUrl(file)
const texture = markRaw(await Texture.fromURL(dataUrl))
resource.texture = texture as any
resource.previewUrl = dataUrl
resource.textureFolder = ''
resource.textureName = file.name.replace(/\.[^.]+$/, '') || 'particle'
input.value = ''
pendingResourceId.value = null
}
async function onSequenceFiles(event: Event) {
const input = event.target as HTMLInputElement
const files = Array.from(input.files || []).sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }))
const config = sys.value?.config
const resource = config?.imageResources.find((item) => item.id === pendingResourceId.value)
if (!files.length || !resource) { input.value = ''; return }
resource.sequenceFrames = await Promise.all(files.map(async (file) => {
const previewUrl = await readFileAsDataUrl(file)
return {
name: file.name,
previewUrl,
texture: markRaw(await Texture.fromURL(previewUrl)) as any,
}
}))
resource.imageMode = 'sequence'
input.value = ''
pendingResourceId.value = null
}
function readFileAsDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result || ''))
reader.onerror = () => reject(reader.error)
reader.readAsDataURL(file)
})
}
function resetTexture(resourceId: number) {
const config = sys.value?.config
const resource = config?.imageResources.find((item) => item.id === resourceId)
if (!config || !resource) return
resource.texture = config.texture
resource.textureName = 'star'
resource.textureFolder = ''
resource.previewUrl = '/images/star.png'
resource.imageMode = 'fixed'
resource.sequencePlayback = 'loop-forward'
resource.sequenceFps = 30
resource.sequenceFrames = []
resource.scale = 1
resource.anchorX = 0.5
resource.anchorY = 0.5
resource.colorMode = 'fixed'
resource.colorStart = '#ffffff'
resource.colorEnd = '#ff4d4d'
resource.colorGradient = [{ position: 0, color: '#ffffff' }, { position: 1, color: '#ff4d4d' }]
resource.blend = 'normal'
resource.independentAlpha = 'off'
resource.alpha = 1
resource.alphaCurveMode = '跟随导出'
resource.alphaCurve = RESOURCE_ALPHA_CURVE.map((point) => ({ ...point }))
resource.locked = false
rebalanceImageWeights()
}
function normalizeHex(value: string, fallback: string) {
let hex = value.trim().replace(/^#/, '')
if (/^[0-9a-f]{3}$/i.test(hex)) hex = hex.split('').map((character) => character + character).join('')
return /^[0-9a-f]{6}$/i.test(hex) ? `#${hex.toLowerCase()}` : fallback
}
function setResourceFixedColor(resource: { colorStart: string }, event: Event) {
const input = event.target as HTMLInputElement
resource.colorStart = normalizeHex(input.value, resource.colorStart)
input.value = resource.colorStart
}
function commitResourceFixedColor(resource: { colorStart: string }, event: KeyboardEvent) {
setResourceFixedColor(resource, event)
;(event.target as HTMLInputElement).blur()
}
function removeImageResource(resourceId: number) {
const resources = sys.value?.config.imageResources
if (!resources || resources.length <= 1) return
const index = resources.findIndex((resource) => resource.id === resourceId)
if (index >= 0) resources.splice(index, 1)
rebalanceImageWeights()
}
function roundWeight(value: number) {
return Math.round(value)
}
type WeightResource = { weight: number; locked: boolean }
function distributeImageWeights(resources: WeightResource[], total: number, proportional = false) {
if (!resources.length) return
const safeTotal = Math.max(0, roundWeight(total))
const sourceTotal = proportional
? resources.reduce((sum, resource) => sum + Math.max(0, Number(resource.weight) || 0), 0)
: 0
let assigned = 0
resources.forEach((resource, index) => {
const isLast = index === resources.length - 1
const next = isLast
? roundWeight(safeTotal - assigned)
: roundWeight(sourceTotal > 0
? safeTotal * Math.max(0, Number(resource.weight) || 0) / sourceTotal
: safeTotal / resources.length)
resource.weight = next
assigned += next
})
}
function rebalanceImageWeights() {
const resources = sys.value?.config.imageResources
if (!resources?.length) return
if (resources.length === 1) {
resources[0].weight = 100
resources[0].locked = false
return
}
const locked = resources.filter((resource) => resource.locked)
const unlocked = resources.filter((resource) => !resource.locked)
let lockedTotal = locked.reduce((sum, resource) => sum + Math.max(0, Number(resource.weight) || 0), 0)
if (lockedTotal > 100) {
distributeImageWeights(locked, 100, true)
lockedTotal = 100
}
distributeImageWeights(unlocked, 100 - lockedTotal)
}
function setResourceWeight(resourceId: number, value: number) {
const resources = sys.value?.config.imageResources
if (!resources?.length) return
if (resources.length === 1) {
resources[0].weight = 100
resources[0].locked = false
return
}
const resource = resources.find((item) => item.id === resourceId)
if (!resource || resource.locked) return
const lockedTotal = resources
.filter((item) => item.id !== resourceId && item.locked)
.reduce((sum, item) => sum + Math.max(0, Number(item.weight) || 0), 0)
const available = Math.max(0, 100 - lockedTotal)
const others = resources.filter((item) => item.id !== resourceId && !item.locked)
resource.weight = roundWeight(Math.min(available, Math.max(0, Number(value) || 0)))
if (!others.length) {
resource.weight = roundWeight(available)
return
}
distributeImageWeights(others, available - resource.weight, true)
}
function toggleResourceLock(resourceId: number) {
const resources = sys.value?.config.imageResources
if (!resources || resources.length === 1) return
const resource = resources.find((item) => item.id === resourceId)
if (resource) resource.locked = !resource.locked
}
return { 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 }
}
import { LINEAR_CURVE, FLAT_CURVE, ALPHA_CURVE, RESOURCE_ALPHA_CURVE, TRAIL_ALPHA_CURVE, TRAIL_SHAPE_CURVE } from './particlePanelCurves'
@@ -0,0 +1,106 @@
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 useProjectPresets(store: ReturnType<typeof useParticleStore>) {
const configFileInput = ref<HTMLInputElement | null>(null)
const selectedPreset = ref('')
const presetOptions = ref<Array<{ name: string; file: string }>>([])
const presetLoading = ref(false)
const configMessage = ref('')
const configError = ref(false)
function saveConfig() {
downloadEditorConfig(store)
configError.value = false
configMessage.value = '配置已保存到本地'
}
async function onConfigFileChange(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
configMessage.value = '正在加载配置…'
configError.value = false
try {
await loadEditorConfigFile(store, file)
configMessage.value = `已加载:${file.name}`
} catch (error) {
configError.value = true
configMessage.value = error instanceof Error ? error.message : String(error)
} finally {
input.value = ''
}
}
async function refreshPresets() {
try {
const response = await fetch(`/Preset/index.json?t=${Date.now()}`, { cache: 'no-store' })
if (!response.ok) throw new Error('无法读取预设列表')
const presets = await response.json()
presetOptions.value = Array.isArray(presets)
? presets.filter((item): item is { name: string; file: string } => !!item && typeof item.name === 'string' && typeof item.file === 'string')
: []
} catch {
presetOptions.value = []
}
}
async function loadSelectedPreset() {
const file = selectedPreset.value
if (!file) return
presetLoading.value = true
configError.value = false
configMessage.value = '正在加载预设…'
const transfer = beginTransfer('加载预设', `正在下载 ${file}…`)
try {
await transfer.paint()
const response = await fetch(`/Preset/${encodeURIComponent(file)}`, { cache: 'no-store' })
if (!response.ok) throw new Error(`无法读取预设:${file}`)
const text = await readResponseTextWithProgress(response, (progress) => {
transfer.update(progress == null ? null : progress * 55, `正在下载 ${file}…`)
})
transfer.update(58, '正在解析预设配置…')
transfer.update(70, '正在解析并恢复预设资源…')
await loadEditorConfigText(store, text)
configMessage.value = `已加载预设:${file.replace(/\.json$/i, '')}`
await transfer.finish('预设加载完成')
} catch (error) {
configError.value = true
const message = error instanceof Error ? error.message : String(error)
configMessage.value = `${message},请检查预设文件命名`
await transfer.fail(message)
} finally {
presetLoading.value = false
selectedPreset.value = ''
}
}
onMounted(refreshPresets)
return { configFileInput, selectedPreset, presetOptions, presetLoading, configMessage, configError, saveConfig, onConfigFileChange, refreshPresets, loadSelectedPreset }
}
+102
View File
@@ -0,0 +1,102 @@
import { Text, type Application, type Container, type Graphics } from 'pixi.js'
import type { Ref } from 'vue'
import type { useParticleStore, CollisionBody, ScenePath } from '../../store/particleStore'
import type { ParticleState } from '../../core/particleTypes'
import type { SpineRuntimeLayer } from '../../spine/SpineRuntimeLayer'
import type { BonePreview, SceneTransformTarget } from './stageTypes'
import { pathLocalToWorld } from './stageGeometry'
type EditorStore = ReturnType<typeof useParticleStore>
// 自适应刻度步进(仿时间轴):返回 1/2/5 序列里的最合适步长,保证屏幕间距合理
export function niceStep(target: number, minStep = 0): number {
if (target <= 0) return 1
const pow = Math.pow(10, Math.floor(Math.log10(target)))
const candidates = [1, 2, 5, 10, 20, 50, 100]
let best = 1
for (const c of candidates) {
if (c * pow >= target) { best = c * pow; break }
}
return Math.max(best, minStep)
}
// 画无限长 x/y 轴 + 自适应整数刻度 + 数值(0,0 在画布中心)
export function drawAxis(store: EditorStore, app: Application | null, axisGfx: Graphics | null, axisLabelLayer: Container | null, axisLabels: Text[]) {
if (!axisGfx) return
const g = axisGfx
g.clear()
const S = store.settings
if (!S.tickEnabled) { // 未启用刻度:隐藏整个坐标轴(轴+刻度+数值)
for (const t of axisLabels) t.visible = false
return
}
const cx = 0, cy = 0 // 世界原点(中心)
const halfW = app!.screen.width / store.editor.viewScale
const halfH = app!.screen.height / store.editor.viewScale
const axisColor = Number('0x' + S.axisColor.replace('#', ''))
// 轴线(贯穿到视口边缘外)
g.lineStyle(1.5, axisColor, 0.35)
g.moveTo(-halfW, cy); g.lineTo(halfW, cy) // x 轴
g.moveTo(cx, -halfH); g.lineTo(cx, halfH) // y 轴
// 刻度步进:目标 ~50px 一个刻度(世界单位 = px/viewScale)
const sc = store.editor.viewScale
const step = niceStep(50 / sc)
// 数值显示范围:只在中心 ±range 内标数值(避免远端堆积/干扰);范围随视口半宽
const rangeW = halfW
const rangeH = halfH
// 刻度线(贯穿整个视口;仅启用刻度时画)
const tickW = Math.max(1, S.tickWidth)
if (S.tickEnabled) {
for (let x = 0; x <= halfW; x += step) {
const v = Math.round(x / step) * step
if (v === 0) continue
g.lineStyle(tickW, axisColor, 0.25)
g.moveTo(v, cy - 4); g.lineTo(v, cy + 4)
g.moveTo(-v, cy - 4); g.lineTo(-v, cy + 4)
}
for (let y = 0; y <= halfH; y += step) {
const v = Math.round(y / step) * step
if (v === 0) continue
g.lineStyle(tickW, axisColor, 0.25)
g.moveTo(cx - 4, y); g.lineTo(cx + 4, y)
g.moveTo(cx - 4, -y); g.lineTo(cx + 4, -y)
}
}
// 数值标签(文字放 axisLabelLayer,不随 world 缩放,尺寸/清晰度恒定)
// 只在中心 ±range 内标注数值,超出不显示,避免看不清/干扰
const cxS = app!.screen.width / 2 + store.editor.viewX
const cyS = app!.screen.height * 0.5 + store.editor.viewY
const fs = S.axisFontSize // 字号由系统设置手动控制(默认 12px)
const labelColor = Number('0x' + S.axisColor.replace('#', ''))
while (axisLabels.length < 64) {
const t = new Text('', { fontFamily: 'monospace', fontSize: fs, fill: labelColor })
t.anchor.set(0.5, 0.5)
axisLabelLayer!.addChild(t)
axisLabels.push(t)
}
let li = 0
const showLbl = S.showAxisLabels && S.tickEnabled
// x 轴正/负数值(放在轴下方一点)
for (let x = 0; x <= halfW; x += step) {
const v = Math.round(x / step) * step
if (v === 0 || !showLbl || v > rangeW) continue
const lbl = axisLabels[li++]; if (!lbl) break
lbl.style.fontSize = fs
lbl.text = String(v); lbl.position.set(cxS + v * sc, cyS + 9); lbl.visible = true
const lvb = axisLabels[li++]; if (!lvb) break
lvb.style.fontSize = fs
lvb.text = String(-v); lvb.position.set(cxS - v * sc, cyS + 9); lvb.visible = true
}
// y 轴(数值放在轴右侧)
for (let y = 0; y <= halfH; y += step) {
const v = Math.round(y / step) * step
if (v === 0 || !showLbl || v > rangeH) continue
const lbl = axisLabels[li++]; if (!lbl) break
lbl.style.fontSize = fs
lbl.text = String(v); lbl.position.set(cxS + 8, cyS - y * sc); lbl.visible = true
const lvb = axisLabels[li++]; if (!lvb) break
lvb.style.fontSize = fs
lvb.text = String(-v); lvb.position.set(cxS + 8, cyS + y * sc); lvb.visible = true
}
// 隐藏多余标签
while (li < axisLabels.length) { const t = axisLabels[li++]; if (t) t.visible = false }
}
+104
View File
@@ -0,0 +1,104 @@
import type { Application, Container, Graphics, Text } from 'pixi.js'
import type { Ref } from 'vue'
import type { useParticleStore, CollisionBody, ScenePath } from '../../store/particleStore'
import type { ParticleState } from '../../core/particleTypes'
import type { SpineRuntimeLayer } from '../../spine/SpineRuntimeLayer'
import type { BonePreview, SceneTransformTarget } from './stageTypes'
import { pathLocalToWorld } from './stageGeometry'
type EditorStore = ReturnType<typeof useParticleStore>
export function appendStateBones(target: BonePreview[], state: ParticleState, parentX: number, parentY: number) {
// 粒子快照同时保存局部坐标和烘焙世界坐标。骨骼调试图层挂在 world 下,必须使用
// 世界坐标,才能与发生位移、旋转、缩放或跟随后的粒子保持逐点重合。
const hasWorldPosition = Number.isFinite(state.worldX) && Number.isFinite(state.worldY)
const hasWorldRotation = Number.isFinite(state.worldRotation)
target.push({
boneName: state.boneName,
x: hasWorldPosition ? state.worldX! : state.x,
y: hasWorldPosition ? -state.worldY! : state.y,
rotation: hasWorldRotation ? -state.worldRotation! : -state.rotation,
parentX,
parentY,
})
if (!state.trailState?.bones.length) return
state.trailState.bones.forEach((bone) => {
// 导出结构中拖尾骨骼全部直接挂在粒子系统骨骼下,画布连线也保持同级语义。
const hasBoneWorldPosition = Number.isFinite(bone.worldX) && Number.isFinite(bone.worldY)
const hasBoneWorldRotation = Number.isFinite(bone.worldRotation)
target.push({
boneName: bone.boneName,
x: hasBoneWorldPosition ? bone.worldX! : bone.x,
y: hasBoneWorldPosition ? -bone.worldY! : bone.y,
rotation: hasBoneWorldRotation ? -bone.worldRotation! : bone.rotation,
parentX,
parentY,
trail: true,
})
})
}
export function drawDirectionalAxis(
g: Graphics,
x: number,
y: number,
directionX: number,
directionY: number,
length: number,
width: number,
color: number,
) {
const negativeLength = length * 0.62
const tipX = x + directionX * length
const tipY = y + directionY * length
const headLength = Math.max(2.5, length * 0.32)
const headWidth = Math.max(1.7, length * 0.22)
const baseX = tipX - directionX * headLength
const baseY = tipY - directionY * headLength
const perpendicularX = -directionY
const perpendicularY = directionX
g.lineStyle(width, color, 0.95)
g.moveTo(x - directionX * negativeLength, y - directionY * negativeLength)
g.lineTo(baseX, baseY)
g.lineStyle(0)
g.beginFill(color, 0.95)
g.drawPolygon([
tipX, tipY,
baseX + perpendicularX * headWidth, baseY + perpendicularY * headWidth,
baseX - perpendicularX * headWidth, baseY - perpendicularY * headWidth,
])
g.endFill()
}
export function drawBones(bones: BonePreview[], store: EditorStore, boneGfx: Graphics | null) {
if (!boneGfx) return
const g = boneGfx
g.clear()
if (!store.settings.showBones) return
const cx = 0, cy = 0
const viewScale = Math.max(0.25, Math.abs(store.editor.viewScale) || 1)
const pointSize = Math.min(10, Math.max(0.1, Number(store.settings.bonePointDrawSize) || 1))
const axisSize = Math.min(10, Math.max(0.1, Number(store.settings.boneAxisDrawSize) || 2))
// 所有尺寸先定义为屏幕像素,再除以画布缩放,保证缩放画布时视觉大小不变。
const pointRadius = 2.5 * pointSize / viewScale
const forwardLength = 4.5 * axisSize / viewScale
const forwardWidth = Math.max(0.65, axisSize * 0.625) / viewScale
// root → 每颗粒子骨骼 的连线
for (const b of bones) {
const color = b.trail ? 0xff4d8d : 0xff8c5a
g.lineStyle(1, color, b.trail ? 0.65 : 0.35)
g.moveTo(b.parentX ?? cx, b.parentY ?? cy)
g.lineTo(b.x, b.y)
// 局部坐标轴为两根交叉轴线,仅在 +X、+Y 端绘制箭头。
const angle = b.rotation * Math.PI / 180
const cos = Math.cos(angle), sin = Math.sin(angle)
drawDirectionalAxis(g, b.x, b.y, cos, sin, forwardLength, forwardWidth, 0xef5350)
drawDirectionalAxis(g, b.x, b.y, sin, -cos, forwardLength, forwardWidth, 0x55c96b)
// 骨骼点
g.beginFill(color, 0.9)
g.drawCircle(b.x, b.y, pointRadius)
g.endFill()
}
}
+160
View File
@@ -0,0 +1,160 @@
import type { Application, Container, Graphics, Text } from 'pixi.js'
import type { Ref } from 'vue'
import type { useParticleStore, CollisionBody, ScenePath } from '../../store/particleStore'
import type { ParticleState } from '../../core/particleTypes'
import type { SpineRuntimeLayer } from '../../spine/SpineRuntimeLayer'
import type { BonePreview, SceneTransformTarget } from './stageTypes'
import { pathLocalToWorld } from './stageGeometry'
type EditorStore = ReturnType<typeof useParticleStore>
import { normalizedRadialRange, normalizedRectRange } from '../../core/emitterShape'
export function drawOrigin(store: EditorStore, emitterPreviewTransform: Map<number, { x: number; y: number; rotation: number }>, originGfx: Graphics | null) {
if (!originGfx) return
const g = originGfx
g.clear()
if (!store.settings.showEmitter) return
// 每个系统对应一个发射点(坐标点);没有系统时不画
if (!store.systems.length) return
for (const sys of store.systems) {
if (sys.visible === false) continue
const preview = emitterPreviewTransform.get(sys.id)
const cx = preview?.x ?? sys.config.centerX ?? 0
const cy = -(preview?.y ?? sys.config.centerY ?? 0)
const active = store.activeObjectType === 'particle' && sys.id === store.activeId
const col = active ? 0xffcc33 : 0x8a94b8
const opacity = active ? 0.4 : 0.2
g.lineStyle(1, col, opacity)
g.drawCircle(cx, cy, 10)
g.beginFill(col, active ? 0.8 : 0.4)
g.drawCircle(cx, cy, 3)
g.endFill()
g.lineStyle(1, col, active ? 0.3 : 0.15)
g.moveTo(cx - 16, cy); g.lineTo(cx + 16, cy)
g.moveTo(cx, cy - 16); g.lineTo(cx, cy + 16)
}
}
// 绘制当前选中系统的发射器形状范围(点/路径不额外绘制),随参数实时响应。
export function drawShapeRange(store: EditorStore, emitterPreviewTransform: Map<number, { x: number; y: number; rotation: number }>, shapeGfx: Graphics | null) {
if (!shapeGfx) return
const g = shapeGfx
g.clear()
if (!store.settings.showEmitter) return
const sys = store.systems.find((s) => s.id === store.activeId)
if (!sys || sys.visible === false) return
const c = sys.config
const preview = emitterPreviewTransform.get(sys.id)
// root 中心(发射器容器 position = centerX/-centerY),数学坐标
const cx = preview?.x ?? c.centerX ?? 0
const cy = -(preview?.y ?? c.centerY ?? 0)
const shape = c.shape
if (shape === 'point' || shape === 'path') return
// 相对 root 中心的局部点(lx,ly,数学坐标)先加偏移(发射源 = root 中心 + offset),再应用 root 缩放+旋转,加 root 中心 → 世界坐标
const sxr = c.rootScaleX ?? 1, syr = c.rootScaleY ?? 1
const rot = -(preview?.rotation ?? c.rootRotation ?? 0) * Math.PI / 180 // 数学上正 → 屏幕:逆时针为正
const cs = Math.cos(rot), sn = Math.sin(rot)
const oxo = c.offsetX ?? 0, oyo = c.offsetY ?? 0
const pt = (lx: number, ly: number): [number, number] => {
// 对齐粒子:局部 y 为数学上正 → 屏幕取负;发射源偏移 offsetY(数学上正)→ 屏幕也取负
const X = lx + oxo, Y = -ly - oyo
const x = X * sxr, y = Y * syr
return [cx + x * cs - y * sn, cy + (x * sn + y * cs)]
}
if (shape === 'circle') {
const range = normalizedRadialRange(c.innerRadius, c.outerRadius)
g.lineStyle(2, 0x3ce86a, 0.7)
// 圆环:以 root 变换后的椭圆近似(逐点)
const steps = 48
for (const radius of [range.outer, range.inner]) {
if (radius <= 0) continue
for (let i = 0; i <= steps; i++) {
const a = i / steps * Math.PI * 2
const [px, py] = pt(Math.cos(a) * radius, Math.sin(a) * radius)
if (i === 0) g.moveTo(px, py); else g.lineTo(px, py)
}
}
} else if (shape === 'rect') {
const range = normalizedRectRange(c)
g.lineStyle(2, 0x4a8cff, 0.7)
for (const [width, height] of [[range.outerW, range.outerH], [range.innerW, range.innerH]]) {
if (width <= 0 || height <= 0) continue
const hw = width / 2, hh = height / 2
const p1 = pt(-hw, -hh), p2 = pt(hw, -hh), p3 = pt(hw, hh), p4 = pt(-hw, hh)
g.moveTo(p1[0], p1[1]); g.lineTo(p2[0], p2[1]); g.lineTo(p3[0], p3[1]); g.lineTo(p4[0], p4[1]); g.lineTo(p1[0], p1[1])
}
} else if (shape === 'cone') {
const range = normalizedRadialRange(c.innerRadius, c.outerRadius)
const half = (c.coneAngle / 2) * Math.PI / 180
const base = c.coneDirection * Math.PI / 180
// 锥形环带的两条边、内弧和外弧。
const innerEdge1 = pt(Math.cos(base - half) * range.inner, Math.sin(base - half) * range.inner)
const innerEdge2 = pt(Math.cos(base + half) * range.inner, Math.sin(base + half) * range.inner)
const outerEdge1 = pt(Math.cos(base - half) * range.outer, Math.sin(base - half) * range.outer)
const outerEdge2 = pt(Math.cos(base + half) * range.outer, Math.sin(base + half) * range.outer)
g.lineStyle(2, 0x3ce86a, 0.7)
g.moveTo(innerEdge1[0], innerEdge1[1]); g.lineTo(outerEdge1[0], outerEdge1[1])
g.moveTo(innerEdge2[0], innerEdge2[1]); g.lineTo(outerEdge2[0], outerEdge2[1])
g.lineStyle(1, 0x3ce86a, 0.5)
const steps = 24
for (const radius of [range.outer, range.inner]) {
if (radius <= 0) continue
for (let i = 0; i <= steps; i++) {
const a = base - half + (2 * half * i / steps)
const [px, py] = pt(Math.cos(a) * radius, Math.sin(a) * radius)
if (i === 0) g.moveTo(px, py); else g.lineTo(px, py)
}
}
}
}
/** 绘制当前选中系统的全局力场;不应用粒子系统 root 变换。 */
export function drawForceField(store: EditorStore, forceFieldGfx: Graphics | null) {
if (!forceFieldGfx) return
const g = forceFieldGfx
g.clear()
const sys = store.systems.find((item) => item.id === store.activeId)
if (sys?.visible === false) return
const cfg = sys?.config
if (!cfg?.forceField || cfg.forceRadius <= 0) return
const x = cfg.forceCenterX
const y = -cfg.forceCenterY
const color = cfg.forceStrength >= 0 ? 0x58c7ff : 0xff6b7a
g.lineStyle(2, color, 0.85)
g.beginFill(color, 0.08)
g.drawCircle(x, y, cfg.forceRadius)
g.endFill()
g.lineStyle(1, color, 0.7)
g.moveTo(x - 9, y); g.lineTo(x + 9, y)
g.moveTo(x, y - 9); g.lineTo(x, y + 9)
g.beginFill(color, 0.95)
g.drawCircle(x, y, 4)
g.endFill()
}
/** 绘制全局吸附点与吸附完成半径;半径为 0 时显示为红色点。 */
export function drawAttraction(store: EditorStore, attractionGfx: Graphics | null) {
if (!attractionGfx) return
const g = attractionGfx
g.clear()
const sys = store.systems.find((item) => item.id === store.activeId)
if (sys?.visible === false) return
const cfg = sys?.config
if (!cfg?.attraction) return
const x = cfg.attractionCenterX
const y = -cfg.attractionCenterY
const radius = Math.max(0, cfg.attractionRadius)
const color = 0xff4d5f
if (radius > 0) {
g.lineStyle(2, color, 0.95)
g.beginFill(color, 0.09)
g.drawCircle(x, y, radius)
g.endFill()
}
g.lineStyle(1, color, 0.85)
g.moveTo(x - 10, y); g.lineTo(x + 10, y)
g.moveTo(x, y - 10); g.lineTo(x, y + 10)
g.beginFill(color, 1)
g.drawCircle(x, y, radius > 0 ? 4 : 6)
g.endFill()
}
+209
View File
@@ -0,0 +1,209 @@
import type { Application, Container, Graphics, Text } from 'pixi.js'
import type { Ref } from 'vue'
import type { useParticleStore, CollisionBody, ScenePath } from '../../store/particleStore'
import type { ParticleState } from '../../core/particleTypes'
import type { SpineRuntimeLayer } from '../../spine/SpineRuntimeLayer'
import type { BonePreview, SceneTransformTarget } from './stageTypes'
import { pathLocalToWorld } from './stageGeometry'
type EditorStore = ReturnType<typeof useParticleStore>
/** 标出层级树当前选中的骨骼;发射器骨骼跟随复用同一目标点。 */
export function drawBoneFollowTargets(store: EditorStore, spineRuntime: SpineRuntimeLayer | null, boneFollowTargetGfx: Graphics | null) {
if (!boneFollowTargetGfx) return
const g = boneFollowTargetGfx
g.parent?.addChild(g)
g.clear()
const targets = new Map<string, { spineId: number; boneName: string }>()
for (const spine of store.spines) {
if (spine.visible === false || !spine.selectedBone || spine.status !== 'ready') continue
targets.set(`${spine.id}:${spine.selectedBone}`, { spineId: spine.id, boneName: spine.selectedBone })
}
for (const system of store.systems) {
if (system.visible === false) continue
const config = system.config
if (!config.emitterFollow || config.emitterFollowMode !== 'bone') continue
if (!config.emitterFollowSpineId || !config.emitterFollowBoneName) continue
const spine = store.spines.find((item) => item.id === config.emitterFollowSpineId)
if (!spine || spine.visible === false) continue
targets.set(`${config.emitterFollowSpineId}:${config.emitterFollowBoneName}`, {
spineId: config.emitterFollowSpineId,
boneName: config.emitterFollowBoneName,
})
}
for (const item of targets.values()) {
const target = spineRuntime?.getBoneWorldTransform(item.spineId, item.boneName)
if (!target) continue
const x = target.x
const y = -target.y
g.lineStyle(2, 0xc48cff, 0.95)
g.beginFill(0xc48cff, 0.2)
g.drawCircle(x, y, 8)
g.endFill()
g.beginFill(0xf2e7ff, 1)
g.drawCircle(x, y, 3.5)
g.endFill()
}
}
// 当前场景对象的变换手柄:跟随其位置、旋转和缩放。
export function drawGizmo(activeTool: Ref<'' | 'translate' | 'rotate' | 'scale'>, selectedTransformTarget: () => SceneTransformTarget | null, gizmoGfx: Graphics | null) {
if (!gizmoGfx) return
const g = gizmoGfx
g.clear()
if (!activeTool.value) return
const target = selectedTransformTarget()
if (!target) return
const cx = target.x
const cy = -target.y
const sxr = target.scaleX, syr = target.scaleY
const rot = -target.rotation * Math.PI / 180
const cs = Math.cos(rot), sn = Math.sin(rot)
// 局部点(数学坐标,ly 上正) → 屏幕点(应用 root 缩放+旋转+平移到 root 中心)
const pt = (lx: number, ly: number): [number, number] => {
const x = lx * sxr, y = (-ly) * syr
return [cx + x * cs - y * sn, cy + (x * sn + y * cs)]
}
const line = (ax: number, ay: number, bx: number, by: number) => {
const a = pt(ax, ay), b = pt(bx, by)
g.moveTo(a[0], a[1]); g.lineTo(b[0], b[1])
}
const R = 26
// 中心点
g.lineStyle(2, 0x35d0ff, 0.9)
g.beginFill(0x35d0ff, 0.25)
g.drawCircle(cx, cy, 6)
g.endFill()
if (activeTool.value === 'translate') {
// 十字箭头
g.lineStyle(2, 0x35d0ff, 0.9)
line(-R, 0, R, 0); line(0, -R, 0, R)
line(R - 6, -4, R, 0); line(R, 0, R - 6, 4) // 右箭头
line(-(R - 6), -4, -R, 0); line(-R, 0, -(R - 6), 4) // 左箭头
line(-4, R - 6, 0, R); line(0, R, 4, R - 6) // 上箭头
line(-4, -(R - 6), 0, -R); line(0, -R, 4, -(R - 6)) // 下箭头
} else if (activeTool.value === 'rotate') {
// 旋转外圈(经缩放/旋转后可能是椭圆)
g.lineStyle(2, 0x35d0ff, 0.8)
const steps = 48
for (let i = 0; i <= steps; i++) {
const a = i / steps * Math.PI * 2
const [px, py] = pt(Math.cos(a) * R, Math.sin(a) * R)
if (i === 0) g.moveTo(px, py); else g.lineTo(px, py)
}
// 指向点:固定于局部顶部(0° 刻度在 12 点方向),经 pt(含 root 旋转)后随 rootRotation 旋转。
// 默认(rootRotation=0)时指向点位于顶部;旋转时它随 root 一起转,反映当前旋转角。
const [tx, ty] = pt(0, R)
g.beginFill(0x35d0ff, 0.95)
g.drawCircle(tx, ty, 4)
g.endFill()
} else if (activeTool.value === 'scale') {
// 四角方点(经缩放/旋转后为旋转矩形)
g.lineStyle(2, 0x35d0ff, 0.9)
const corners: [number, number][] = [[-R, -R], [R, -R], [R, R], [-R, R]]
const pts = corners.map(([ox, oy]) => pt(ox, oy))
g.moveTo(pts[0][0], pts[0][1]); g.lineTo(pts[1][0], pts[1][1]); g.lineTo(pts[2][0], pts[2][1]); g.lineTo(pts[3][0], pts[3][1]); g.lineTo(pts[0][0], pts[0][1])
g.beginFill(0x35d0ff, 0.9)
for (const p of pts) g.drawRect(p[0] - 3, p[1] - 3, 6, 6)
g.endFill()
}
}
export function colorNumber(value: string, fallback = 0xffffff) {
const parsed = Number.parseInt(value.replace('#', ''), 16)
return Number.isFinite(parsed) ? parsed : fallback
}
export function drawCollisionBodies(store: EditorStore, previewColliders: CollisionBody[], collisionGfx: Graphics | null) {
if (!collisionGfx) return
const g = collisionGfx
g.clear()
for (const collider of previewColliders) {
if (collider.visible === false) continue
const selected = store.activeObjectType === 'collision' && store.activeObjectId === collider.id
const color = collider.enabled ? (selected ? 0xff5e72 : 0xd4475b) : 0x586174
const angle = collider.rotation * Math.PI / 180
const cos = Math.cos(angle), sin = Math.sin(angle)
const scaleX = Math.max(0.0001, Math.abs(collider.scaleX ?? 1))
const scaleY = Math.max(0.0001, Math.abs(collider.scaleY ?? 1))
const point = (x: number, y: number) => ({
x: collider.x + cos * x * scaleX - sin * y * scaleY,
y: -(collider.y + sin * x * scaleX + cos * y * scaleY),
})
let points: Array<{ x: number; y: number }> = []
if (collider.shape === 'circle' || collider.shape === 'ellipse') {
const radiusX = collider.shape === 'circle' ? collider.radius : collider.radiusX
const radiusY = collider.shape === 'circle' ? collider.radius : collider.radiusY
points = Array.from({ length: 49 }, (_, index) => {
const theta = index / 48 * Math.PI * 2
return point(Math.cos(theta) * radiusX, Math.sin(theta) * radiusY)
})
} else if (collider.shape === 'rect') {
const hw = collider.width * 0.5, hh = collider.height * 0.5
points = [point(-hw, -hh), point(hw, -hh), point(hw, hh), point(-hw, hh), point(-hw, -hh)]
} else {
const sides = Math.max(3, Math.min(16, Math.round(collider.sides)))
points = Array.from({ length: sides + 1 }, (_, index) => {
const theta = Math.PI * 0.5 + (index % sides) / sides * Math.PI * 2
return point(Math.cos(theta) * collider.width * 0.5, Math.sin(theta) * collider.height * 0.5)
})
}
if (!points.length) continue
g.lineStyle(selected ? 2.5 : 1.5, color, collider.enabled ? 0.95 : 0.45)
g.beginFill(color, selected ? 0.12 : 0.055)
g.moveTo(points[0].x, points[0].y)
for (let i = 1; i < points.length; i++) g.lineTo(points[i].x, points[i].y)
g.endFill()
const center = point(0, 0)
g.lineStyle(1, color, 0.8)
g.moveTo(center.x - 7, center.y); g.lineTo(center.x + 7, center.y)
g.moveTo(center.x, center.y - 7); g.lineTo(center.x, center.y + 7)
}
}
export function drawPaths(store: EditorStore, selectedPathNodes: Set<number>, pathGfx: Graphics | null) {
if (!pathGfx) return
const g = pathGfx
g.clear()
for (const path of store.paths) {
if (path.visible === false || !path.enabled || !path.points.length) continue
const selected = store.activeObjectType === 'path' && store.activeObjectId === path.id
const color = colorNumber(path.color, 0xff933e)
const screenPoint = (x: number, y: number) => {
const worldPoint = pathLocalToWorld(path, x, y)
return { x: worldPoint.x, y: -worldPoint.y }
}
const first = path.points[0]
const start = screenPoint(first.x, first.y)
g.lineStyle(selected ? 2.5 : 1.8, color, selected ? 1 : 0.75)
g.moveTo(start.x, start.y)
const segmentCount = path.closed ? path.points.length : path.points.length - 1
for (let i = 0; i < segmentCount; i++) {
const a = path.points[i]
const b = path.points[(i + 1) % path.points.length]
const controlA = screenPoint(a.x + a.outX, a.y + a.outY)
const controlB = screenPoint(b.x + b.inX, b.y + b.inY)
const end = screenPoint(b.x, b.y)
g.bezierCurveTo(controlA.x, controlA.y, controlB.x, controlB.y, end.x, end.y)
}
if (!selected) continue
path.points.forEach((anchor, index) => {
const point = screenPoint(anchor.x, anchor.y)
const input = screenPoint(anchor.x + anchor.inX, anchor.y + anchor.inY)
const output = screenPoint(anchor.x + anchor.outX, anchor.y + anchor.outY)
const hasInput = Math.hypot(anchor.inX, anchor.inY) > 0.01
const hasOutput = Math.hypot(anchor.outX, anchor.outY) > 0.01
g.lineStyle(1, 0xa9b4cb, 0.65)
if (hasInput) { g.moveTo(point.x, point.y); g.lineTo(input.x, input.y) }
if (hasOutput) { g.moveTo(point.x, point.y); g.lineTo(output.x, output.y) }
if (hasInput) { g.beginFill(0xd5dcec, 0.95); g.drawCircle(input.x, input.y, 3); g.endFill() }
if (hasOutput) { g.beginFill(0xd5dcec, 0.95); g.drawCircle(output.x, output.y, 3); g.endFill() }
g.beginFill(selectedPathNodes.has(index) ? 0xffffff : color, 1)
g.lineStyle(1.5, selectedPathNodes.has(index) ? 0x5d7cff : 0xffffff, 0.95)
g.drawCircle(point.x, point.y, selectedPathNodes.has(index) ? 5 : 4)
g.endFill()
})
}
}
+56
View File
@@ -0,0 +1,56 @@
import type { ScenePath } from '../../store/particleStore'
export function pathLocalToWorld(path: ScenePath, x: number, y: number) {
const angle = path.rotation * Math.PI / 180
const cos = Math.cos(angle), sin = Math.sin(angle)
const scaleX = Math.max(0.0001, Math.abs(path.scaleX ?? 1))
const scaleY = Math.max(0.0001, Math.abs(path.scaleY ?? 1))
return { x: path.x + cos * x * scaleX - sin * y * scaleY, y: path.y + sin * x * scaleX + cos * y * scaleY }
}
export function worldToPathLocal(path: ScenePath, x: number, y: number) {
const angle = path.rotation * Math.PI / 180
const cos = Math.cos(angle), sin = Math.sin(angle)
const scaleX = Math.max(0.0001, Math.abs(path.scaleX ?? 1))
const scaleY = Math.max(0.0001, Math.abs(path.scaleY ?? 1))
const dx = x - path.x, dy = y - path.y
return { x: (cos * dx + sin * dy) / scaleX, y: (-sin * dx + cos * dy) / scaleY }
}
export type FollowCurvePoint = { x: number; y: number; inX?: number; inY?: number; outX?: number; outY?: number }
export function followCurveValue(points: FollowCurvePoint[], t: number, mode: 'linear' | 'bezier' = 'linear') {
if (!points.length) return t
const sorted = [...points].sort((a, b) => a.x - b.x)
if (t <= sorted[0].x) return sorted[0].y
for (let index = 1; index < sorted.length; index++) {
const a = sorted[index - 1], b = sorted[index]
if (t <= b.x) {
let amount = (t - a.x) / Math.max(0.0001, b.x - a.x)
if (mode === 'bezier') {
const x1 = a.x + (a.outX ?? (b.x - a.x) / 3)
const x2 = b.x + (b.inX ?? -(b.x - a.x) / 3)
let low = 0, high = 1
for (let step = 0; step < 18; step++) {
const middle = (low + high) / 2
if (cubicPoint(a.x, x1, x2, b.x, middle) < t) low = middle
else high = middle
}
amount = (low + high) / 2
return cubicPoint(a.y, a.y + (a.outY ?? (b.y - a.y) / 3), b.y + (b.inY ?? -(b.y - a.y) / 3), b.y, amount)
}
return a.y + (b.y - a.y) * amount
}
}
return sorted[sorted.length - 1].y
}
export function cubicPoint(a: number, b: number, c: number, d: number, t: number) {
const u = 1 - t
return u * u * u * a + 3 * u * u * t * b + 3 * u * t * t * c + t * t * t * d
}
+15
View File
@@ -0,0 +1,15 @@
// 粒子→骨骼映射:每个活动粒子 = 一根挂在 root 下的骨骼
export type BonePreview = { boneName: string; x: number; y: number; rotation: number; parentX?: number; parentY?: number; trail?: boolean }
export type SceneTransformTarget = {
x: number
y: number
rotation: number
scaleX: number
scaleY: number
setPosition: (x: number, y: number) => void
setRotation: (rotation: number) => void
setScale: (scaleX: number, scaleY: number) => void
}