资源拆分
This commit is contained in:
1 parent
f810e712ec
commit
83890faaea
38 files changed
+4586
-4144
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
+7
-1095
File diff suppressed because it is too large.
Load diff
@@ -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]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+19
-2359
File diff suppressed because it is too large.
Load diff
+17
-609
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user