阶段11开发
@@ -0,0 +1,492 @@
|
||||
{
|
||||
"format": "SpineParticle.EditorConfig",
|
||||
"version": 1,
|
||||
"savedAt": "2026-08-31T09:38:45.071Z",
|
||||
"editor": {
|
||||
"viewScale": 6.866040888412048,
|
||||
"viewX": -86.9344445981659,
|
||||
"viewY": 14.683070674664151
|
||||
},
|
||||
"settings": {
|
||||
"tickEnabled": false,
|
||||
"axisFontSize": 12,
|
||||
"tickWidth": 1,
|
||||
"showAxisLabels": true,
|
||||
"axisColor": "#7a86ad",
|
||||
"undoLimit": 20,
|
||||
"backgroundX": 0,
|
||||
"backgroundY": 0,
|
||||
"backgroundOpacity": 1,
|
||||
"backgroundImageName": "",
|
||||
"backgroundImageData": "",
|
||||
"showEmitter": true,
|
||||
"showBones": true,
|
||||
"showSkinMesh": false,
|
||||
"showGrid": true,
|
||||
"exportSettings": {
|
||||
"imagesPath": "./images/",
|
||||
"spineVersion": "4.2",
|
||||
"keyframeCurve": "linear",
|
||||
"fps": 30,
|
||||
"omitFps": false,
|
||||
"integerFrameAlignment": true,
|
||||
"bonePool": true,
|
||||
"excludeEmptyAnimations": false,
|
||||
"fileName": "SpineParticle.json"
|
||||
}
|
||||
},
|
||||
"timeline": {
|
||||
"fps": 30,
|
||||
"loop": true,
|
||||
"animations": [
|
||||
"animation"
|
||||
]
|
||||
},
|
||||
"selection": {
|
||||
"type": "particle",
|
||||
"id": 1
|
||||
},
|
||||
"scene": {
|
||||
"systems": [
|
||||
{
|
||||
"id": 1,
|
||||
"visible": true,
|
||||
"animation": "animation",
|
||||
"config": {
|
||||
"imageResources": [
|
||||
{
|
||||
"id": 1,
|
||||
"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": "burst",
|
||||
"rate": 20,
|
||||
"streamBehavior": "normal",
|
||||
"loopDurationFrames": 30,
|
||||
"generateLoopStartAnimation": false,
|
||||
"generateLoopEndAnimation": false,
|
||||
"burstCount": 1,
|
||||
"burstLoop": false,
|
||||
"delay": 0,
|
||||
"duration": 1,
|
||||
"shape": "point",
|
||||
"radius": 30,
|
||||
"rectW": 60,
|
||||
"rectH": 40,
|
||||
"centerX": 0,
|
||||
"centerY": 0,
|
||||
"rootRotation": 0,
|
||||
"rootScaleX": 1,
|
||||
"rootScaleY": 1,
|
||||
"direction": 0,
|
||||
"spread": 360,
|
||||
"coneAngle": 60,
|
||||
"lifeMin": 1,
|
||||
"lifeMax": 1.5,
|
||||
"lifeMode": "fixed",
|
||||
"lifeCurve": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1
|
||||
}
|
||||
],
|
||||
"speedMin": 271,
|
||||
"speedMax": 307,
|
||||
"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": true,
|
||||
"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": true,
|
||||
"trailConfigVersion": 3,
|
||||
"trailResources": [
|
||||
{
|
||||
"id": 1,
|
||||
"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": 5,
|
||||
"length": 100,
|
||||
"width": 100,
|
||||
"widthMode": "particle",
|
||||
"lifeLengthEnabled": false,
|
||||
"lifeLengthCurve": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1
|
||||
}
|
||||
],
|
||||
"shapeEnabled": true,
|
||||
"shapeCurve": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"trailResourceVersion": 5,
|
||||
"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": 5,
|
||||
"collision": false,
|
||||
"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
|
||||
}
|
||||
}
|
||||
],
|
||||
"colliders": [],
|
||||
"paths": [],
|
||||
"spines": []
|
||||
}
|
||||
}
|
||||
@@ -22,5 +22,13 @@
|
||||
{
|
||||
"name": "默认粒子",
|
||||
"file": "默认粒子.json"
|
||||
},
|
||||
{
|
||||
"name": "TrailTest",
|
||||
"file": "TrailTest.json"
|
||||
},
|
||||
{
|
||||
"name": "yanhua",
|
||||
"file": "yanhua.json"
|
||||
}
|
||||
]
|
||||
|
||||
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 527 B After Width: | Height: | Size: 527 B |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
@@ -11,7 +11,7 @@ export type AttributeMode = 'fixed' | 'random' | 'curve'
|
||||
/** 初始旋转额外支持跟随发射方向 */
|
||||
export type RotationMode = AttributeMode | 'direction'
|
||||
export type ScaleAxisMode = 'uniform' | 'separate'
|
||||
export interface CurvePoint { x: number; y: number }
|
||||
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'
|
||||
@@ -91,8 +91,16 @@ export interface TrailImageResource {
|
||||
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[]
|
||||
@@ -279,7 +287,7 @@ export interface EmitterConfig {
|
||||
trailAlphaMode: 'particle' | 'fixed' | 'curve'
|
||||
trailAlpha: number
|
||||
trailAlphaCurve: CurvePoint[]
|
||||
/** 每个粒子的拖尾骨骼链数量 */
|
||||
/** 每个粒子的同级拖尾骨骼数量 */
|
||||
trailBoneCount: number
|
||||
trailLength: number
|
||||
trailWidth: number
|
||||
@@ -317,11 +325,19 @@ export interface EmitterConfig {
|
||||
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
|
||||
@@ -329,7 +345,7 @@ export interface ParticleState {
|
||||
imageFrameIndex?: number
|
||||
/** 单次播放结束后可只隐藏图片,粒子和拖尾仍继续存在。 */
|
||||
imageVisible?: boolean
|
||||
/** 拖尾拥有独立骨骼链和蒙皮状态,录制/回放时一并保存。 */
|
||||
/** 拖尾拥有一组同级骨骼和蒙皮状态,录制/回放时一并保存。 */
|
||||
trailState?: TrailState
|
||||
active: boolean
|
||||
}
|
||||
@@ -339,10 +355,15 @@ export interface TrailBoneState {
|
||||
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
|
||||
@@ -357,7 +378,7 @@ export function defaultConfig(): EmitterConfig {
|
||||
texture: null,
|
||||
textureName: 'star',
|
||||
textureFolder: '',
|
||||
previewUrl: '/particles/star.png',
|
||||
previewUrl: '/images/star.png',
|
||||
imageMode: 'fixed',
|
||||
sequencePlayback: 'loop-forward',
|
||||
sequenceFps: 30,
|
||||
@@ -499,7 +520,7 @@ export function defaultConfig(): EmitterConfig {
|
||||
id: 1,
|
||||
texture: null,
|
||||
textureName: 'trail',
|
||||
previewUrl: '/trails/trail.png',
|
||||
previewUrl: '/images/trail.png',
|
||||
weight: 100,
|
||||
locked: false,
|
||||
rotation: 0,
|
||||
@@ -511,18 +532,25 @@ export function defaultConfig(): EmitterConfig {
|
||||
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: 5,
|
||||
trailResourceVersion: 6,
|
||||
trailTexture: null,
|
||||
trailTextureName: 'trail',
|
||||
trailPreviewUrl: '/trails/trail.png',
|
||||
trailPreviewUrl: '/images/trail.png',
|
||||
trailColorMode: 'particle',
|
||||
trailColor: '#ffffff',
|
||||
trailColorGradient: [{ position: 0, color: '#ffffff' }, { position: 1, color: '#ffffff' }],
|
||||
@@ -575,10 +603,10 @@ export function ensureEmitterConfig(config: EmitterConfig): EmitterConfig {
|
||||
const legacyForceStrength = Number(target.forceStrength)
|
||||
const needsTrailCurveMigration = target.trailConfigVersion !== 3
|
||||
const needsTrailResourceMigration = !Array.isArray(target.trailResources)
|
||||
const needsTrailResourceStyleMigration = target.trailResourceVersion !== 5
|
||||
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 || '/trails/trail.png')
|
||||
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)
|
||||
@@ -597,7 +625,8 @@ export function ensureEmitterConfig(config: EmitterConfig): EmitterConfig {
|
||||
for (const resource of config.imageResources) {
|
||||
if (!resource.texture && config.texture) resource.texture = config.texture
|
||||
if (!resource.textureName) resource.textureName = 'star'
|
||||
if (!resource.previewUrl) resource.previewUrl = resource.textureName === 'star' ? '/particles/star.png' : ''
|
||||
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'
|
||||
@@ -689,8 +718,15 @@ export function ensureEmitterConfig(config: EmitterConfig): EmitterConfig {
|
||||
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 })),
|
||||
@@ -700,7 +736,8 @@ export function ensureEmitterConfig(config: EmitterConfig): EmitterConfig {
|
||||
}
|
||||
for (const resource of config.trailResources) {
|
||||
if (!resource.textureName) resource.textureName = 'trail'
|
||||
if (!resource.previewUrl) resource.previewUrl = '/trails/trail.png'
|
||||
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
|
||||
@@ -716,8 +753,16 @@ export function ensureEmitterConfig(config: EmitterConfig): EmitterConfig {
|
||||
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) {
|
||||
@@ -737,7 +782,7 @@ export function ensureEmitterConfig(config: EmitterConfig): EmitterConfig {
|
||||
resource.alphaCurve = resource.alphaCurve?.length >= 2 ? resource.alphaCurve : [{ x: 0, y: 1 }, { x: 1, y: 1 }]
|
||||
}
|
||||
}
|
||||
config.trailResourceVersion = 5
|
||||
config.trailResourceVersion = 6
|
||||
return config
|
||||
}
|
||||
|
||||
@@ -745,6 +790,7 @@ interface Particle {
|
||||
sprite: Sprite
|
||||
active: boolean
|
||||
boneName: string
|
||||
spawnId: number
|
||||
life: number
|
||||
maxLife: number
|
||||
x: number; y: number
|
||||
@@ -772,6 +818,9 @@ interface Particle {
|
||||
trailCurrentAlpha: number
|
||||
trailCurrentColor: number
|
||||
trailResourceId: number
|
||||
trailEnabled: boolean
|
||||
trailSampledLength: number
|
||||
trailSampledWidth: number
|
||||
resourceId: number
|
||||
imageFrameIndex: number
|
||||
collisionPaused: boolean
|
||||
@@ -803,6 +852,7 @@ export class ParticleEmitter extends Container {
|
||||
private followSpawnX = 0
|
||||
private followSpawnY = 0
|
||||
private followSpawnAngle = 0
|
||||
private followExportCurveMode: '线性' | '贝塞尔' = '线性'
|
||||
private simulationTransform: { centerX: number; centerY: number; rotation: number; scaleX?: number; scaleY?: number } | null = null
|
||||
|
||||
constructor(config: EmitterConfig, maxParticles = MAX_PARTICLES) {
|
||||
@@ -812,6 +862,11 @@ export class ParticleEmitter extends Container {
|
||||
this.addChild(this.trailDebugGfx)
|
||||
}
|
||||
|
||||
setFollowExportCurveMode(mode: '线性' | '贝塞尔') { this.followExportCurveMode = mode }
|
||||
private resolveCurveMode(mode: '跟随导出' | '线性' | '贝塞尔') {
|
||||
return mode === '跟随导出' ? this.followExportCurveMode : mode
|
||||
}
|
||||
|
||||
/** 仅在种子变化时重建随机源;种子不变时让序列持续推进,保证"确定性随机序列"语义 */
|
||||
private reseed() {
|
||||
const s = this.cfg.seed
|
||||
@@ -830,6 +885,7 @@ export class ParticleEmitter extends Container {
|
||||
this.addChild(sprite)
|
||||
this.pool.push({
|
||||
sprite, active: false, boneName: 'p_' + poolIndex,
|
||||
spawnId: -1,
|
||||
life: 0, maxLife: 1, x: 0, y: 0, vx: 0, vy: 0,
|
||||
scaleStartX: 1, scaleStartY: 1, alphaStart: 1, alphaEnd: 1, rotation: 0, rotationSpeed: 0,
|
||||
speedScale: 1, speedScaleTarget: 1, scaleOverLifeX: 1, scaleOverLifeY: 1,
|
||||
@@ -851,6 +907,9 @@ export class ParticleEmitter extends Container {
|
||||
trailCurrentAlpha: 0,
|
||||
trailCurrentColor: 0xffffff,
|
||||
trailResourceId: 1,
|
||||
trailEnabled: false,
|
||||
trailSampledLength: 100,
|
||||
trailSampledWidth: 100,
|
||||
resourceId: 1,
|
||||
imageFrameIndex: 0,
|
||||
collisionPaused: false,
|
||||
@@ -941,7 +1000,9 @@ export class ParticleEmitter extends Container {
|
||||
this.burstTimer += dt
|
||||
if (cfg.burstLoop) {
|
||||
if (this.burstTimer >= 1) { this.burstTimer = 0; this.emitBurst() }
|
||||
} else if (this.burstTimer <= dt) {
|
||||
// update(0) 只用于两个固定帧之间刷新画面,不能被识别为首次爆发;
|
||||
// 否则 0 <= 0 会在第 1 帧前额外生成一批粒子。
|
||||
} else if (dt > 0 && this.burstTimer <= dt) {
|
||||
this.emitBurst()
|
||||
}
|
||||
}
|
||||
@@ -1104,13 +1165,13 @@ export class ParticleEmitter extends Container {
|
||||
}
|
||||
s.scale.set(finite(p.scaleStartX * scaleMulX, 1), finite(p.scaleStartY * scaleMulY, 1))
|
||||
const baseAlpha = cfg.alphaMode === 'curve'
|
||||
? curveAt(cfg.alphaCurve, t) * cfg.alphaScale
|
||||
? curveAt(cfg.alphaCurve, t, this.resolveCurveMode(cfg.curveMode)) * cfg.alphaScale
|
||||
: cfg.alpha * cfg.alphaScale
|
||||
// 开启卡片独立透明度后,覆盖系统透明度;固定值不会再受系统曲线影响。
|
||||
s.alpha = finite(resource?.independentAlpha === 'fixed'
|
||||
? resource.alpha
|
||||
: resource?.independentAlpha === 'curve'
|
||||
? curveAt(resource.alphaCurve, t)
|
||||
? curveAt(resource.alphaCurve, t, this.resolveCurveMode(resource.alphaCurveMode))
|
||||
: baseAlpha, 1)
|
||||
const colorStart = resource?.colorStart || '#ffffff'
|
||||
const rgb = resource?.colorMode === 'lifetime'
|
||||
@@ -1119,7 +1180,20 @@ export class ParticleEmitter extends Container {
|
||||
s.tint = rgb
|
||||
s.blendMode = BLEND_MAP[resource?.blend || 'normal'] as any
|
||||
const trailState = this.updateTrail(p, renderX, renderY, t, s)
|
||||
states.push({ boneName: p.boneName, x: renderX, y: renderY, rotation: p.rotation, scaleX: s.scale.x, scaleY: s.scale.y, alpha: s.alpha, colorHex: rgb, resourceId: p.resourceId, imageFrameIndex: p.imageFrameIndex, imageVisible: s.visible, trailState, active: true })
|
||||
const world = localToWorldMath(cfg, renderX, renderY, this.simulationTransform)
|
||||
const rootRotation = this.simulationTransform?.rotation ?? cfg.rootRotation
|
||||
const rootScaleX = Math.abs(this.simulationTransform?.scaleX ?? cfg.rootScaleX)
|
||||
const rootScaleY = Math.abs(this.simulationTransform?.scaleY ?? cfg.rootScaleY)
|
||||
const worldTrail = trailState
|
||||
? {
|
||||
...trailState,
|
||||
bones: trailState.bones.map((bone) => {
|
||||
const point = localToWorldMath(cfg, bone.x, bone.y, this.simulationTransform)
|
||||
return { ...bone, worldX: point.x, worldY: point.y, worldRotation: rootRotation - bone.rotation }
|
||||
}),
|
||||
}
|
||||
: undefined
|
||||
states.push({ boneName: p.boneName, spawnId: p.spawnId, x: renderX, y: renderY, rotation: p.rotation, scaleX: s.scale.x, scaleY: s.scale.y, worldX: world.x, worldY: world.y, worldRotation: rootRotation + p.rotation, worldScaleX: s.scale.x * rootScaleX, worldScaleY: s.scale.y * rootScaleY, alpha: s.alpha, colorHex: rgb, resourceId: p.resourceId, imageFrameIndex: p.imageFrameIndex, imageVisible: s.visible, trailState: worldTrail, active: true })
|
||||
}
|
||||
this.states = states
|
||||
return states
|
||||
@@ -1205,14 +1279,21 @@ export class ParticleEmitter extends Container {
|
||||
if (p.trailHistory.length > keepCount) p.trailHistory.length = keepCount
|
||||
}
|
||||
|
||||
/** 沿历史轨迹等距生成独立骨骼链;第 0 根骨骼位于粒子头部。 */
|
||||
/** 沿历史轨迹等距生成同级拖尾骨骼;第 0 根骨骼位于粒子头部。 */
|
||||
private sampleTrailBones(p: Particle, length: number, boneCount: number) {
|
||||
const count = Math.max(2, Math.min(16, Math.round(boneCount)))
|
||||
const history = p.trailHistory
|
||||
const result: TrailBoneState[] = []
|
||||
if (!history.length) return result
|
||||
let availableLength = 0
|
||||
for (let index = 1; index < history.length; index++) {
|
||||
availableLength += Math.hypot(history[index].x - history[index - 1].x, history[index].y - history[index - 1].y)
|
||||
}
|
||||
// 轨迹不足完整长度时,不把后方骨骼堆叠在最旧采样点;所有骨骼始终在
|
||||
// 当前有效轨迹内等距插值,并随可用长度增加逐渐展开到配置长度。
|
||||
const sampledLength = Math.min(Math.max(0, length), availableLength)
|
||||
for (let boneIndex = 0; boneIndex < count; boneIndex++) {
|
||||
const targetDistance = length * boneIndex / (count - 1)
|
||||
const targetDistance = sampledLength * boneIndex / (count - 1)
|
||||
let traversed = 0
|
||||
let point = history[history.length - 1]
|
||||
for (let i = 1; i < history.length; i++) {
|
||||
@@ -1235,6 +1316,16 @@ export class ParticleEmitter extends Container {
|
||||
const dy = i < result.length - 1 ? next.y - result[i].y : result[i].y - previous.y
|
||||
result[i].rotation = Math.atan2(dy, dx) * 180 / Math.PI
|
||||
}
|
||||
if (sampledLength + 0.0001 < Math.max(0, length) && result.length > 1) {
|
||||
// 拖尾仍在展开阶段时,位置已经在当前有效轨迹中等距插值;旋转也在
|
||||
// 头尾切线之间使用最短角度插值,避免同级骨骼驱动的网格发生方向折跳。
|
||||
const startRotation = result[0].rotation
|
||||
const endRotation = result[result.length - 1].rotation
|
||||
const shortestDelta = ((endRotation - startRotation + 540) % 360) - 180
|
||||
for (let index = 0; index < result.length; index++) {
|
||||
result[index].rotation = startRotation + shortestDelta * index / (result.length - 1)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -1282,7 +1373,7 @@ export class ParticleEmitter extends Container {
|
||||
|
||||
private updateTrail(p: Particle, renderX: number, renderY: number, lifeT: number, sprite: Sprite): TrailState | undefined {
|
||||
const cfg = this.cfg
|
||||
if (!cfg.trail) {
|
||||
if (!cfg.trail || !p.trailEnabled || p.trailSampledLength <= 0 || p.trailSampledWidth <= 0) {
|
||||
p.trailHistory.length = 0
|
||||
p.trailBones.length = 0
|
||||
if (p.trailMesh) p.trailMesh.visible = false
|
||||
@@ -1291,9 +1382,9 @@ export class ParticleEmitter extends Container {
|
||||
// 先确认骨骼/网格签名;发生结构变化时会在记录新轨迹前完成清理和重新绑定。
|
||||
this.ensureTrailMesh(p)
|
||||
const trailResource = cfg.trailResources.find((resource) => resource.id === p.trailResourceId) || cfg.trailResources[0]
|
||||
this.appendTrailPoint(p, renderX, renderY, trailResource?.length ?? 100)
|
||||
this.appendTrailPoint(p, renderX, renderY, p.trailSampledLength)
|
||||
const lengthMultiplier = trailResource?.lifeLengthEnabled ? Math.max(0, curveAt(trailResource.lifeLengthCurve, lifeT)) : 1
|
||||
const length = Math.max(0, trailResource?.length ?? 100) * lengthMultiplier
|
||||
const length = Math.max(0, p.trailSampledLength) * lengthMultiplier
|
||||
p.trailBones = this.sampleTrailBones(p, length, trailResource?.boneCount ?? 3)
|
||||
// 首骨骼必须与粒子最终渲染位置完全一致,避免采样/重绑时出现亚像素脱节。
|
||||
if (p.trailBones[0]) {
|
||||
@@ -1303,7 +1394,7 @@ export class ParticleEmitter extends Container {
|
||||
const particleWidthScale = trailResource?.widthMode === 'particle'
|
||||
? (Math.abs(sprite.scale.x) + Math.abs(sprite.scale.y)) * 0.5
|
||||
: 1
|
||||
p.trailCurrentWidth = Math.max(0, trailResource?.width ?? 100) * particleWidthScale
|
||||
p.trailCurrentWidth = Math.max(0, p.trailSampledWidth) * particleWidthScale
|
||||
p.trailCurrentAlpha = trailResource?.alphaMode === 'particle'
|
||||
? sprite.alpha
|
||||
: trailResource?.alphaMode === 'curve'
|
||||
@@ -1316,6 +1407,9 @@ export class ParticleEmitter extends Container {
|
||||
: hexToNumber(trailResource?.color || '#ffffff')
|
||||
const state: TrailState = {
|
||||
bones: p.trailBones.map((bone) => ({ ...bone })),
|
||||
// 拖尾是一张固定拓扑的加权 Mesh,所有绑定骨骼必须成组存活。
|
||||
// 历史长度不足时所有骨骼会在当前有效轨迹内等距插值,而不是延迟出生。
|
||||
activeBoneCount: p.trailBones.length,
|
||||
width: p.trailCurrentWidth,
|
||||
alpha: Math.min(1, Math.max(0, p.trailCurrentAlpha)),
|
||||
colorHex: p.trailCurrentColor,
|
||||
@@ -1415,6 +1509,7 @@ export class ParticleEmitter extends Container {
|
||||
const p = this.pool[idx]
|
||||
const resource = this.pickImageResource()
|
||||
const spawnSerial = this._spawnSerial++
|
||||
p.spawnId = spawnSerial
|
||||
// 拖尾资源使用独立的确定性采样,不改变粒子属性原有随机序列。
|
||||
const trailResource = this.pickTrailResource(seededUnit(cfg.seed, spawnSerial, 0x5bd1e995))
|
||||
// 周期内的发射序号决定噪声和吸附方向,不依赖复用到的粒子池槽位,确保相邻循环表现一致。
|
||||
@@ -1436,9 +1531,26 @@ export class ParticleEmitter extends Container {
|
||||
p.trailCurrentAlpha = 0
|
||||
p.trailCurrentColor = 0xffffff
|
||||
p.trailResourceId = trailResource.id
|
||||
const ratio = Math.min(100, Math.max(0, Number(trailResource.trailRatio) || 0))
|
||||
const estimatedCount = cfg.mode === 'burst'
|
||||
? Math.max(0, Math.round(cfg.burstCount))
|
||||
: Math.max(0, Math.floor(cfg.rate * (cfg.streamBehavior === 'loop' ? Math.max(1, cfg.loopDurationFrames) / 30 : Math.max(0, cfg.duration))))
|
||||
const forceSingleTrail = ratio > 0 && estimatedCount === 1 && spawnSerial === 0
|
||||
const ratioSample = seededUnit(cfg.seed, spawnSerial, 0x27d4eb2d) * 100
|
||||
const sampleRange = (mode: 'fixed' | 'random', fixed: number, min: number, max: number, salt: number) => {
|
||||
if (mode !== 'random') return Math.max(0, Number(fixed) || 0)
|
||||
const low = Math.max(0, Math.min(Number(min) || 0, Number(max) || 0))
|
||||
const high = Math.max(0, Math.max(Number(min) || 0, Number(max) || 0))
|
||||
return lerp(low, high, seededUnit(cfg.seed, spawnSerial, salt))
|
||||
}
|
||||
p.trailSampledLength = sampleRange(trailResource.lengthMode, trailResource.length, trailResource.lengthMin, trailResource.lengthMax, 0x165667b1)
|
||||
p.trailSampledWidth = sampleRange(trailResource.widthSizeMode, trailResource.width, trailResource.widthMin, trailResource.widthMax, 0xd3a2646c)
|
||||
p.trailEnabled = cfg.trail && (forceSingleTrail || ratioSample < ratio) && p.trailSampledLength > 0 && p.trailSampledWidth > 0
|
||||
if (p.trailMesh) p.trailMesh.visible = false
|
||||
p.active = true; p.life = life; p.maxLife = life
|
||||
p.x = ox; p.y = oy
|
||||
// 从出生点开始积累轨迹,使第一次更新时整组拖尾骨骼就能在首段轨迹上等距插值。
|
||||
if (p.trailEnabled) p.trailHistory.push({ x: ox, y: oy })
|
||||
p.vx = Math.cos(dirAng) * speed
|
||||
p.vy = (cfg.shape === 'cone' ? -Math.sin(dirAng) : Math.sin(dirAng)) * speed
|
||||
p.scaleStartX = initialValue(cfg.scaleMode, cfg.scaleMin, cfg.scaleMax, cfg.scaleCurve, this.rng()) * resource.scale
|
||||
@@ -1476,7 +1588,7 @@ export class ParticleEmitter extends Container {
|
||||
const resources = this.cfg.imageResources.filter((resource) => resource.texture || resource.sequenceFrames.some((frame) => frame.texture))
|
||||
if (!resources.length) return {
|
||||
id: 1, texture: this.cfg.texture, textureName: 'star', textureFolder: '',
|
||||
previewUrl: '/particles/star.png', imageMode: 'fixed', sequencePlayback: 'loop-forward', sequenceFps: 30, sequenceFrames: [],
|
||||
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', blend: 'normal',
|
||||
colorGradient: [{ position: 0, color: '#ffffff' }, { position: 1, color: '#ff4d4d' }],
|
||||
@@ -1518,12 +1630,14 @@ export class ParticleEmitter extends Container {
|
||||
private pickTrailResource(sample: number): TrailImageResource {
|
||||
const resources = this.cfg.trailResources.filter((resource) => resource.texture)
|
||||
if (!resources.length) return this.cfg.trailResources[0] || {
|
||||
id: 1, texture: null, textureName: 'trail', previewUrl: '/trails/trail.png', weight: 100, locked: false,
|
||||
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, length: 100, width: 100, widthMode: 'particle',
|
||||
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 }],
|
||||
}
|
||||
@@ -1569,12 +1683,17 @@ export class ParticleEmitter extends Container {
|
||||
if (!p.active) continue
|
||||
out.push({
|
||||
// 拖尾记录的是最终渲染轨迹,因此粒子本体也必须记录 sprite 坐标(包含移动噪声等渲染偏移)。
|
||||
boneName: p.boneName, x: p.sprite.x, y: p.sprite.y, rotation: p.rotation,
|
||||
boneName: p.boneName, spawnId: p.spawnId, x: p.sprite.x, y: p.sprite.y, rotation: p.rotation,
|
||||
scaleX: p.sprite.scale.x, scaleY: p.sprite.scale.y,
|
||||
...this.captureWorldTransform(p),
|
||||
alpha: p.sprite.alpha, colorHex: p.sprite.tint as number, resourceId: p.resourceId, imageFrameIndex: p.imageFrameIndex, imageVisible: p.sprite.visible, active: true,
|
||||
trailState: this.cfg.trail && p.trailBones.length
|
||||
? {
|
||||
bones: p.trailBones.map((bone) => ({ ...bone })),
|
||||
bones: p.trailBones.map((bone) => {
|
||||
const point = localToWorldMath(this.cfg, bone.x, bone.y, this.simulationTransform)
|
||||
return { ...bone, worldX: point.x, worldY: point.y, worldRotation: (this.simulationTransform?.rotation ?? this.cfg.rootRotation) - bone.rotation }
|
||||
}),
|
||||
activeBoneCount: p.trailBones.length,
|
||||
width: p.trailCurrentWidth,
|
||||
alpha: p.trailCurrentAlpha,
|
||||
colorHex: p.trailCurrentColor,
|
||||
@@ -1586,6 +1705,18 @@ export class ParticleEmitter extends Container {
|
||||
return out
|
||||
}
|
||||
|
||||
private captureWorldTransform(p: Particle) {
|
||||
const point = localToWorldMath(this.cfg, p.sprite.x, p.sprite.y, this.simulationTransform)
|
||||
const rootRotation = this.simulationTransform?.rotation ?? this.cfg.rootRotation
|
||||
return {
|
||||
worldX: point.x,
|
||||
worldY: point.y,
|
||||
worldRotation: rootRotation + p.rotation,
|
||||
worldScaleX: p.sprite.scale.x * Math.abs(this.simulationTransform?.scaleX ?? this.cfg.rootScaleX),
|
||||
worldScaleY: p.sprite.scale.y * Math.abs(this.simulationTransform?.scaleY ?? this.cfg.rootScaleY),
|
||||
}
|
||||
}
|
||||
|
||||
/** 回放:把快照还原到 sprite(非活动粒子隐藏) */
|
||||
apply(states: ParticleState[]) {
|
||||
const byName = new Map<string, ParticleState>()
|
||||
@@ -1813,15 +1944,32 @@ function overLifeValue(mode: AttributeMode, min: number, max: number, curve: Cur
|
||||
return mode === 'random' ? sampled : min
|
||||
}
|
||||
/** 在透明度曲线关键帧上按 x(生命周期时刻 0~1)求 y(透明度 0~1)。保证有序、端点外取端点 */
|
||||
function curveAt(pts: CurvePoint[] | undefined, x: number): number {
|
||||
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
|
||||
}
|
||||
|
||||
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 t = (x - p[i].x) / (p[i + 1].x - p[i].x || 1)
|
||||
return lerp(p[i].y, p[i + 1].y, t)
|
||||
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
|
||||
|
||||
@@ -15,6 +15,7 @@ import { parseSpineBundle, type ParsedSpineBundle } from '../spine/spineAssetLoa
|
||||
import { restoreSpineSource } from '../spine/spineSourceBundle'
|
||||
import { registerSpineAsset, releaseSpineAsset } from '../spine/spineAssetRegistry'
|
||||
import { cloneEditorValue } from './editorClone'
|
||||
import { ensureSpineExportSettings } from '../export/spineExportSettings'
|
||||
|
||||
type EditorStore = ReturnType<typeof useParticleStore>
|
||||
|
||||
@@ -115,7 +116,11 @@ export function parseEditorConfig(text: string): EditorConfigFile {
|
||||
async function hydrateParticleResources(systems: ParticleSystem[]) {
|
||||
const textureCache = new Map<string, Promise<Texture>>()
|
||||
const loadTexture = (url: string) => {
|
||||
const source = url || '/particles/star.png'
|
||||
const source = url === '/particles/star.png'
|
||||
? '/images/star.png'
|
||||
: url === '/trails/trail.png'
|
||||
? '/images/trail.png'
|
||||
: url || '/images/star.png'
|
||||
let pending = textureCache.get(source)
|
||||
if (!pending) {
|
||||
pending = Texture.fromURL(source).then((texture) => markRaw(texture))
|
||||
@@ -123,18 +128,18 @@ async function hydrateParticleResources(systems: ParticleSystem[]) {
|
||||
}
|
||||
return pending
|
||||
}
|
||||
const defaultTexture = await loadTexture('/particles/star.png')
|
||||
const defaultTexture = await loadTexture('/images/star.png')
|
||||
await Promise.all(systems.map(async (system) => {
|
||||
const config = ensureEmitterConfig(system.config as any)
|
||||
config.texture = defaultTexture
|
||||
await Promise.all(config.imageResources.map(async (resource) => {
|
||||
resource.texture = await loadTexture(resource.previewUrl || '/particles/star.png')
|
||||
resource.texture = await loadTexture(resource.previewUrl || '/images/star.png')
|
||||
await Promise.all(resource.sequenceFrames.map(async (frame) => {
|
||||
frame.texture = await loadTexture(frame.previewUrl || resource.previewUrl || '/particles/star.png')
|
||||
frame.texture = await loadTexture(frame.previewUrl || resource.previewUrl || '/images/star.png')
|
||||
}))
|
||||
}))
|
||||
await Promise.all(config.trailResources.map(async (resource) => {
|
||||
resource.texture = await loadTexture(resource.previewUrl || '/trails/trail.png')
|
||||
resource.texture = await loadTexture(resource.previewUrl || '/images/trail.png')
|
||||
}))
|
||||
config.trailTexture = config.trailResources[0]?.texture || null
|
||||
system.frames = []
|
||||
@@ -194,7 +199,10 @@ export async function applyEditorConfig(store: EditorStore, input: EditorConfigF
|
||||
store.spines = spines
|
||||
Object.assign(store.editor, cloneEditorValue(config.editor || {}))
|
||||
Object.assign(store.settings, cloneEditorValue(config.settings || {}))
|
||||
store.settings.exportSettings = ensureSpineExportSettings(config.settings?.exportSettings)
|
||||
store.settings.undoLimit = Math.min(200, Math.max(1, Math.round(Number(store.settings.undoLimit) || 20)))
|
||||
store.settings.bonePointDrawSize = Math.min(10, Math.max(0.1, Number(config.settings?.bonePointDrawSize) || 1))
|
||||
store.settings.boneAxisDrawSize = Math.min(10, Math.max(0.1, Number(config.settings?.boneAxisDrawSize) || 2))
|
||||
store.timeline.fps = Math.max(1, Math.round(Number(config.timeline?.fps) || 30))
|
||||
store.timeline.loop = config.timeline?.loop !== false
|
||||
store.timeline.animations = Array.isArray(config.timeline?.animations) && config.timeline.animations.length
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
export type SpineExportVersion = '4.2'
|
||||
export type SpineExportKeyframeCurve = 'linear' | 'bezier'
|
||||
|
||||
/** Spine JSON 导出的全局参数。实际 JSON 生成逻辑将在后续阶段接入。 */
|
||||
export interface SpineExportSettings {
|
||||
imagesPath: string
|
||||
spineVersion: SpineExportVersion
|
||||
keyframeCurve: SpineExportKeyframeCurve
|
||||
fps: number
|
||||
omitFps: boolean
|
||||
integerFrameAlignment: boolean
|
||||
bonePool: boolean
|
||||
excludeEmptyAnimations: boolean
|
||||
fileName: string
|
||||
}
|
||||
|
||||
export function defaultSpineExportSettings(): SpineExportSettings {
|
||||
return {
|
||||
imagesPath: './images/',
|
||||
spineVersion: '4.2',
|
||||
keyframeCurve: 'linear',
|
||||
fps: 30,
|
||||
omitFps: false,
|
||||
integerFrameAlignment: true,
|
||||
bonePool: true,
|
||||
excludeEmptyAnimations: false,
|
||||
fileName: 'SpineParticle.json',
|
||||
}
|
||||
}
|
||||
|
||||
/** 补齐旧配置或热更新前状态中缺少的导出参数。 */
|
||||
export function ensureSpineExportSettings(value?: Partial<SpineExportSettings> | null): SpineExportSettings {
|
||||
const settings = Object.assign(defaultSpineExportSettings(), value || {})
|
||||
settings.imagesPath = String(settings.imagesPath || './images/')
|
||||
settings.spineVersion = '4.2'
|
||||
settings.keyframeCurve = settings.keyframeCurve === 'bezier' ? 'bezier' : 'linear'
|
||||
settings.fps = Math.min(240, Math.max(1, Math.round(Number(settings.fps) || 30)))
|
||||
settings.omitFps = settings.omitFps === true
|
||||
settings.integerFrameAlignment = settings.integerFrameAlignment !== false
|
||||
settings.bonePool = settings.bonePool !== false
|
||||
settings.excludeEmptyAnimations = settings.excludeEmptyAnimations === true
|
||||
settings.fileName = String(settings.fileName || 'SpineParticle.json')
|
||||
return settings
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
import type { EmitterConfig, ParticleImageResource, ParticleState, TrailImageResource } from '../core/particleEmitter'
|
||||
import type { ParticleSystem, TimelineState } from '../store/particleStore'
|
||||
import type { SpineExportSettings } from './spineExportSettings'
|
||||
import type { SpineExportResult, SpineExportWarning, SpineJsonDocument, SpineJsonMap } from './spineJsonTypes'
|
||||
import { reduceBezierSamples, reduceNumericSamples, roundSpine, unwrapDegrees, type NumericSample } from './spineKeyframeReducer'
|
||||
import { buildWeightedTrailMesh } from './spineMeshBuilder'
|
||||
|
||||
type Track = { key: string; name: string; states: Array<ParticleState | null> }
|
||||
type ExportInput = { systems: ParticleSystem[]; timeline: TimelineState; settings: SpineExportSettings }
|
||||
|
||||
const BLENDS = { normal: 'normal', add: 'additive', multiply: 'multiply', screen: 'screen' } as const
|
||||
const PARTICLE_POSITION_TOLERANCE = 0.1
|
||||
const TRAIL_POSITION_TOLERANCE = 0.1
|
||||
const ROTATION_TOLERANCE = 0.01
|
||||
const SCALE_TOLERANCE = 0.01
|
||||
|
||||
function safeName(value: string, fallback: string) {
|
||||
const name = String(value || '').trim().replace(/[\\/:*?"<>|\s]+/g, '_').replace(/^_+|_+$/g, '')
|
||||
return name || fallback
|
||||
}
|
||||
|
||||
function uniqueName(base: string, used: Set<string>) {
|
||||
let value = base
|
||||
let index = 2
|
||||
while (used.has(value)) value = `${base}_${index++}`
|
||||
used.add(value)
|
||||
return value
|
||||
}
|
||||
|
||||
function stripExtension(value: string) { return value.replace(/\.[^./\\]+$/, '') }
|
||||
function normalizePath(folder: string, file: string) {
|
||||
return stripExtension([folder, file].filter(Boolean).join('/').replace(/\\/g, '/').replace(/\/{2,}/g, '/').replace(/^\.\//, ''))
|
||||
}
|
||||
function textureSize(texture: any) {
|
||||
const width = Number(texture?.orig?.width ?? texture?.width) || 0
|
||||
const height = Number(texture?.orig?.height ?? texture?.height) || 0
|
||||
return { width, height }
|
||||
}
|
||||
function byte(value: number) { return Math.round(Math.max(0, Math.min(1, value)) * 255).toString(16).padStart(2, '0') }
|
||||
function rgbaValues(value: number, alpha: number) {
|
||||
return [((value >> 16) & 255) / 255, ((value >> 8) & 255) / 255, (value & 255) / 255, Math.max(0, Math.min(1, alpha))]
|
||||
}
|
||||
function time(frame: number, fps: number) { return roundSpine(frame / fps) }
|
||||
|
||||
function particleDurationFrames(system: ParticleSystem, fps: number) {
|
||||
const c = system.config
|
||||
const life = c.lifeMode === 'fixed' ? Math.max(0, Number(c.lifeMin) || 0) : Math.max(0, Number(c.lifeMin) || 0, Number(c.lifeMax) || 0)
|
||||
const delay = Math.max(0, Number(c.delay) || 0)
|
||||
let end: number
|
||||
if (c.mode !== 'stream') end = delay + life
|
||||
else if (c.streamBehavior !== 'loop') end = delay + Math.max(1 / fps, Number(c.duration) || 0) + life
|
||||
else {
|
||||
const loop = Math.max(1, Math.ceil(Number(c.loopDurationFrames) || 1)) / fps
|
||||
if (c.generateLoopEndAnimation) end = delay + life + 1 / fps
|
||||
else if (c.generateLoopStartAnimation) end = delay + loop * Math.max(1, Math.ceil(life / loop))
|
||||
else end = delay + loop
|
||||
}
|
||||
return Math.max(1, Math.ceil(end * fps) + 1)
|
||||
}
|
||||
|
||||
function stateIdentity(state: ParticleState, bonePool: boolean) {
|
||||
return bonePool ? state.boneName : `${state.spawnId ?? 'legacy'}_${state.boneName}`
|
||||
}
|
||||
|
||||
function collectTracks(frames: ParticleState[][], bonePool: boolean, usedNames: Set<string>) {
|
||||
const keys = new Set<string>()
|
||||
for (const frame of frames) for (const state of frame) keys.add(stateIdentity(state, bonePool))
|
||||
return [...keys].sort().map((key, index): Track => ({
|
||||
key,
|
||||
name: uniqueName(`_particle_${String(index).padStart(3, '0')}`, usedNames),
|
||||
states: frames.map((frame) => frame.find((state) => stateIdentity(state, bonePool) === key) || null),
|
||||
}))
|
||||
}
|
||||
|
||||
function sourceStateAt(track: Track, targetFrame: number, sourceFps: number, targetFps: number) {
|
||||
const source = targetFrame * sourceFps / targetFps
|
||||
const aIndex = Math.min(track.states.length - 1, Math.max(0, Math.floor(source)))
|
||||
const bIndex = Math.min(track.states.length - 1, aIndex + 1)
|
||||
const a = track.states[aIndex]
|
||||
const b = track.states[bIndex]
|
||||
if (!a || !b || stateIdentity(a, true) !== stateIdentity(b, true) || a.spawnId !== b.spawnId) return a
|
||||
const amount = source - aIndex
|
||||
if (amount <= 1e-6) return a
|
||||
const lerp = (x: number | undefined, y: number | undefined) => (x ?? 0) + ((y ?? x ?? 0) - (x ?? 0)) * amount
|
||||
const trailState = a.trailState && b.trailState && a.trailState.resourceId === b.trailState.resourceId
|
||||
? {
|
||||
...a.trailState,
|
||||
width: lerp(a.trailState.width, b.trailState.width),
|
||||
alpha: lerp(a.trailState.alpha, b.trailState.alpha),
|
||||
bones: a.trailState.bones.map((bone, index) => {
|
||||
const next = b.trailState!.bones[index] || bone
|
||||
return { ...bone, worldX: lerp(bone.worldX ?? bone.x, next.worldX ?? next.x), worldY: lerp(bone.worldY ?? bone.y, next.worldY ?? next.y), worldRotation: lerp(bone.worldRotation ?? -bone.rotation, next.worldRotation ?? -next.rotation) }
|
||||
}),
|
||||
}
|
||||
: a.trailState
|
||||
return {
|
||||
...a,
|
||||
worldX: lerp(a.worldX, b.worldX), worldY: lerp(a.worldY, b.worldY), worldRotation: lerp(a.worldRotation, b.worldRotation),
|
||||
worldScaleX: lerp(a.worldScaleX, b.worldScaleX), worldScaleY: lerp(a.worldScaleY, b.worldScaleY),
|
||||
alpha: lerp(a.alpha, b.alpha),
|
||||
trailState,
|
||||
}
|
||||
}
|
||||
|
||||
type TimelineCurveMode = 'linear' | 'bezier'
|
||||
|
||||
function resolvedTimelineCurve(mode: '跟随导出' | '线性' | '贝塞尔' | undefined, settings: SpineExportSettings): TimelineCurveMode {
|
||||
if (!mode || mode === '跟随导出') return settings.keyframeCurve
|
||||
return mode === '贝塞尔' ? 'bezier' : 'linear'
|
||||
}
|
||||
|
||||
function numericTimeline(samples: NumericSample[], fps: number, names: string[], tolerances: number[], settings: SpineExportSettings, forcedFrames: Set<number> = new Set(), curveMode: TimelineCurveMode = settings.keyframeCurve) {
|
||||
if (!samples.length) return []
|
||||
const fitted = curveMode === 'bezier'
|
||||
? reduceBezierSamples(samples, tolerances, forcedFrames)
|
||||
: { samples: reduceNumericSamples(samples, tolerances, forcedFrames), controls: [] }
|
||||
const keys = fitted.samples.map((sample) => {
|
||||
const key: SpineJsonMap = { time: time(sample.frame, fps) }
|
||||
names.forEach((name, index) => { key[name] = roundSpine(sample.values[index]) })
|
||||
return key
|
||||
})
|
||||
for (let index = 0; index < fitted.controls.length; index++) {
|
||||
const startTime = Number(keys[index].time) || 0
|
||||
const endTime = Number(keys[index + 1].time) || 0
|
||||
const curve: number[] = []
|
||||
for (let dimension = 0; dimension < names.length; dimension++) {
|
||||
const controls = fitted.controls[index].values[dimension]
|
||||
curve.push(roundSpine(startTime + (endTime - startTime) / 3), roundSpine(controls[0]), roundSpine(startTime + (endTime - startTime) * 2 / 3), roundSpine(controls[1]))
|
||||
}
|
||||
keys[index].curve = curve
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
function stateBoundaryFrames(states: Array<ParticleState | null>) {
|
||||
const forced = new Set<number>()
|
||||
for (let frame = 0; frame < states.length; frame++) {
|
||||
const current = states[frame]
|
||||
const previous = frame ? states[frame - 1] : null
|
||||
const changedParticle = !!current && !!previous && current.spawnId !== previous.spawnId
|
||||
if ((!!current !== !!previous) || changedParticle) {
|
||||
if (frame > 0 && previous) forced.add(frame - 1)
|
||||
if (current) forced.add(frame)
|
||||
}
|
||||
}
|
||||
return forced
|
||||
}
|
||||
|
||||
function optionalStateBoundaryFrames<T>(states: Array<T | undefined>) {
|
||||
const forced = new Set<number>()
|
||||
for (let frame = 0; frame < states.length; frame++) {
|
||||
const current = states[frame], previous = frame ? states[frame - 1] : undefined
|
||||
if (!!current !== !!previous) {
|
||||
if (frame > 0 && previous) forced.add(frame - 1)
|
||||
if (current) forced.add(frame)
|
||||
}
|
||||
}
|
||||
return forced
|
||||
}
|
||||
|
||||
function trailShapeAt(resource: TrailImageResource, t: number) {
|
||||
if (!resource.shapeEnabled || !resource.shapeCurve?.length) return 1
|
||||
const points = [...resource.shapeCurve].sort((a, b) => a.x - b.x)
|
||||
if (t <= points[0].x) return Math.max(0, points[0].y)
|
||||
for (let index = 1; index < points.length; index++) {
|
||||
const a = points[index - 1], b = points[index]
|
||||
if (t <= b.x) {
|
||||
const amount = (t - a.x) / Math.max(0.000001, b.x - a.x)
|
||||
return Math.max(0, a.y + (b.y - a.y) * amount)
|
||||
}
|
||||
}
|
||||
return Math.max(0, points[points.length - 1].y)
|
||||
}
|
||||
|
||||
function discreteTimeline<T>(values: T[], fps: number, make: (value: T, frame: number) => SpineJsonMap) {
|
||||
const keys: SpineJsonMap[] = []
|
||||
let previous: T | symbol = Symbol()
|
||||
values.forEach((value, frame) => { if (value !== previous) { keys.push(make(value, frame)); previous = value } })
|
||||
return keys
|
||||
}
|
||||
|
||||
function resourceAttachment(resource: ParticleImageResource, frameIndex: number, warnings: SpineExportWarning[]) {
|
||||
const frame = resource.imageMode === 'sequence' ? resource.sequenceFrames[frameIndex] : undefined
|
||||
const file = frame?.name || resource.textureName || `image_${resource.id}`
|
||||
const texture = frame?.texture || resource.texture
|
||||
const size = textureSize(texture)
|
||||
if (!size.width || !size.height) warnings.push({ code: 'missing-image', message: `图片“${file}”缺失或尺寸不可读,已保留路径。` })
|
||||
const width = size.width || 1, height = size.height || 1
|
||||
return {
|
||||
path: normalizePath(resource.textureFolder, file),
|
||||
x: roundSpine((0.5 - resource.anchorX) * width),
|
||||
y: roundSpine((resource.anchorY - 0.5) * height),
|
||||
width: roundSpine(width), height: roundSpine(height),
|
||||
}
|
||||
}
|
||||
|
||||
function rgbaTimeline(colors: number[][], attachments: Array<string | null>, fps: number, settings: SpineExportSettings, curveMode: TimelineCurveMode = settings.keyframeCurve) {
|
||||
if (!colors.length) return []
|
||||
const forcedFrames = new Set<number>()
|
||||
for (let frame = 1; frame < attachments.length; frame++) {
|
||||
const current = attachments[frame]
|
||||
const previous = attachments[frame - 1]
|
||||
// 序列帧图片名变化不影响同一插槽上的 RGBA 插值;仅在显隐或资源卡片切换时锁定边界。
|
||||
const currentFamily = current?.replace(/_\d+$/, '') ?? null
|
||||
const previousFamily = previous?.replace(/_\d+$/, '') ?? null
|
||||
if (currentFamily !== previousFamily) {
|
||||
forcedFrames.add(frame)
|
||||
forcedFrames.add(frame - 1)
|
||||
}
|
||||
}
|
||||
// 透明度使用未量化的浮点值参与精简,线性渐变能直接收敛到首尾关键帧;
|
||||
// RGB 保持单通道 1/255,透明度使用 0.01 的视觉容差。
|
||||
const samples: NumericSample[] = colors.map((values, frame) => ({ frame, values }))
|
||||
const rgbTolerance = 1 / 255
|
||||
const fitted = curveMode === 'bezier'
|
||||
? reduceBezierSamples(samples, [rgbTolerance, rgbTolerance, rgbTolerance, 0.01], forcedFrames)
|
||||
: { samples: reduceNumericSamples(samples, [rgbTolerance, rgbTolerance, rgbTolerance, 0.01], forcedFrames), controls: [] }
|
||||
const keys = fitted.samples.map((sample) => ({ time: time(sample.frame, fps), color: `${byte(sample.values[0])}${byte(sample.values[1])}${byte(sample.values[2])}${byte(sample.values[3])}` } as SpineJsonMap))
|
||||
if (fitted.controls.length) {
|
||||
for (let index = 0; index < fitted.controls.length; index++) {
|
||||
const t0 = time(fitted.samples[index].frame, fps), t1 = time(fitted.samples[index + 1].frame, fps)
|
||||
const curve: number[] = []
|
||||
for (let channel = 0; channel < 4; channel++) {
|
||||
const controls = fitted.controls[index].values[channel]
|
||||
curve.push(roundSpine(t0 + (t1 - t0) / 3), roundSpine(controls[0]), roundSpine(t0 + (t1 - t0) * 2 / 3), roundSpine(controls[1]))
|
||||
}
|
||||
keys[index].curve = curve
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
function addSlotAnimation(animation: SpineJsonMap, slotName: string, attachments: Array<string | null>, colors: number[][], fps: number, settings: SpineExportSettings, curveMode: TimelineCurveMode = settings.keyframeCurve) {
|
||||
const slot = animation.slots[slotName] ||= {}
|
||||
const attachment = discreteTimeline(attachments, fps, (name, frame) => name == null ? { time: time(frame, fps) } : { time: time(frame, fps), name })
|
||||
if (attachment.length) slot.attachment = attachment
|
||||
const rgba = rgbaTimeline(colors, attachments, fps, settings, curveMode)
|
||||
if (rgba.length) slot.rgba = rgba
|
||||
}
|
||||
|
||||
export function buildSpineJson(input: ExportInput): SpineExportResult {
|
||||
const settings = input.settings
|
||||
const sourceFps = Math.max(1, Number(input.timeline.fps) || 30)
|
||||
const targetFps = Math.max(1, Math.round(settings.fps || 30))
|
||||
// 关闭整数帧对齐时保留编辑器原始采样时刻,仅修改 Spine 的 FPS 元数据。
|
||||
const fps = settings.integerFrameAlignment ? targetFps : sourceFps
|
||||
const warnings: SpineExportWarning[] = []
|
||||
const visibleSystems = input.systems.filter((system) => system.visible !== false)
|
||||
const document: SpineJsonDocument = {
|
||||
skeleton: { hash: '', spine: '4.2', x: 0, y: 0, width: 0, height: 0, images: settings.imagesPath || './images/' },
|
||||
bones: [{ name: 'root' }], slots: [], skins: [{ name: 'default', attachments: {} }], animations: {},
|
||||
}
|
||||
if (!settings.omitFps) document.skeleton.fps = targetFps
|
||||
const skin = document.skins[0].attachments as SpineJsonMap
|
||||
const usedNames = new Set<string>(['root'])
|
||||
const animationNames = [...new Set([...(input.timeline.animations.length ? input.timeline.animations : ['animation']), ...visibleSystems.map((system) => system.animation || 'animation')])]
|
||||
const animationSystems = new Map<string, Array<{ system: ParticleSystem; tracks: Track[]; targetFrames: number }>>()
|
||||
|
||||
for (const system of visibleSystems) {
|
||||
const sourceCount = Math.min(system.frames?.length || 0, particleDurationFrames(system, sourceFps))
|
||||
const frames = (system.frames || []).slice(0, sourceCount)
|
||||
if (!frames.length) warnings.push({ code: 'empty-cache', message: `“${system.config.name}”没有可用帧缓存。` })
|
||||
const prefix = uniqueName(safeName(system.config.name, `system_${system.id}`), usedNames)
|
||||
document.bones.push({ name: prefix, parent: 'root' })
|
||||
const tracks = collectTracks(frames, settings.bonePool, usedNames)
|
||||
const duration = Math.max(0, (sourceCount - 1) / sourceFps)
|
||||
const targetFrames = Math.max(1, Math.ceil(duration * fps) + 1)
|
||||
const group = animationSystems.get(system.animation) || []
|
||||
group.push({ system, tracks, targetFrames })
|
||||
animationSystems.set(system.animation, group)
|
||||
|
||||
for (const track of tracks) {
|
||||
document.bones.push({ name: track.name, parent: prefix })
|
||||
const usedImageBlends = new Set(system.config.imageResources.map((item) => item.blend))
|
||||
for (const blend of usedImageBlends) {
|
||||
const slotName = `${track.name}_image_${blend}`
|
||||
document.slots.push({ name: slotName, bone: track.name, color: 'ffffff00', blend: BLENDS[blend] })
|
||||
const slotSkin = skin[slotName] ||= {}
|
||||
for (const resource of system.config.imageResources.filter((item) => item.blend === blend)) {
|
||||
const count = resource.imageMode === 'sequence' ? Math.max(1, resource.sequenceFrames.length) : 1
|
||||
for (let frame = 0; frame < count; frame++) slotSkin[`image_${resource.id}_${frame}`] = resourceAttachment(resource, frame, warnings)
|
||||
}
|
||||
}
|
||||
if (system.config.trail) {
|
||||
for (const resource of system.config.trailResources) {
|
||||
if (!track.states.some((state) => state?.trailState?.resourceId === resource.id)) continue
|
||||
const count = Math.max(2, Math.min(16, Math.round(resource.boneCount || 3)))
|
||||
const trailNames: string[] = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
const name = `${track.name}_trail_${resource.id}_${i}`
|
||||
trailNames.push(name)
|
||||
document.bones.push({
|
||||
name,
|
||||
parent: prefix,
|
||||
})
|
||||
}
|
||||
const slotName = `${track.name}_trail_${resource.id}`
|
||||
document.slots.push({ name: slotName, bone: trailNames[0], color: 'ffffff00', blend: BLENDS[resource.blend] })
|
||||
const indices = trailNames.map((name) => document.bones.findIndex((bone) => bone.name === name))
|
||||
const path = normalizePath('', resource.textureName || `trail_${resource.id}`)
|
||||
;(skin[slotName] ||= {})[`trail_${resource.id}`] = buildWeightedTrailMesh(system.config, resource, indices, path)
|
||||
if (!resource.texture) warnings.push({ code: 'missing-image', message: `拖尾图片“${resource.textureName || resource.id}”缺失,已保留路径。` })
|
||||
}
|
||||
}
|
||||
// Spine 按 slots 数组顺序绘制。保持每个粒子的拖尾在图片下方。
|
||||
const imagePrefix = `${track.name}_image_`
|
||||
const trailPrefix = `${track.name}_trail_`
|
||||
const ownSlots = document.slots.filter((slot) => String(slot.name).startsWith(imagePrefix) || String(slot.name).startsWith(trailPrefix))
|
||||
if (ownSlots.length) {
|
||||
document.slots = document.slots.filter((slot) => !ownSlots.includes(slot))
|
||||
document.slots.push(...ownSlots.filter((slot) => String(slot.name).startsWith(trailPrefix)), ...ownSlots.filter((slot) => String(slot.name).startsWith(imagePrefix)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const animationName of animationNames) {
|
||||
const entries = animationSystems.get(animationName) || []
|
||||
const animation: SpineJsonMap = { bones: {}, slots: {} }
|
||||
let hasContent = false
|
||||
for (const { system, tracks, targetFrames } of entries) {
|
||||
for (const track of tracks) {
|
||||
const states = Array.from({ length: targetFrames }, (_, frame) => sourceStateAt(track, frame, sourceFps, fps))
|
||||
const active = states.map((state) => !!state?.active)
|
||||
const positions: NumericSample[] = [], rotations: number[] = [], scales: NumericSample[] = []
|
||||
states.forEach((state, frame) => {
|
||||
if (!state?.active) return
|
||||
positions.push({ frame, values: [state.worldX ?? state.x, state.worldY ?? state.y] })
|
||||
rotations.push(state.worldRotation ?? state.rotation)
|
||||
scales.push({ frame, values: [state.worldScaleX ?? state.scaleX, state.worldScaleY ?? state.scaleY] })
|
||||
})
|
||||
const rotationValues = unwrapDegrees(rotations)
|
||||
const rotationSamples: NumericSample[] = positions.map((sample, index) => ({ frame: sample.frame, values: [rotationValues[index] ?? 0] }))
|
||||
const motionBoundaries = stateBoundaryFrames(states)
|
||||
const boneTimeline: SpineJsonMap = {}
|
||||
const translate = numericTimeline(positions, fps, ['x', 'y'], [PARTICLE_POSITION_TOLERANCE, PARTICLE_POSITION_TOLERANCE], settings, motionBoundaries)
|
||||
const rotate = numericTimeline(rotationSamples, fps, ['value'], [ROTATION_TOLERANCE], settings, motionBoundaries)
|
||||
const scale = numericTimeline(scales, fps, ['x', 'y'], [SCALE_TOLERANCE, SCALE_TOLERANCE], settings, motionBoundaries)
|
||||
if (translate.length) boneTimeline.translate = translate
|
||||
if (rotate.length) boneTimeline.rotate = rotate
|
||||
if (scale.length) boneTimeline.scale = scale
|
||||
if (Object.keys(boneTimeline).length) { animation.bones[track.name] = boneTimeline; hasContent = true }
|
||||
|
||||
const usedBlends = new Set(system.config.imageResources.map((item) => item.blend))
|
||||
for (const blend of usedBlends) {
|
||||
const attachments: Array<string | null> = [], colors: number[][] = []
|
||||
let lastColor = 0xffffff
|
||||
for (let frame = 0; frame < targetFrames; frame++) {
|
||||
const state = states[frame]
|
||||
const resource = state ? system.config.imageResources.find((item) => item.id === state.resourceId) : undefined
|
||||
const visible = !!state && active[frame] && state.imageVisible !== false && resource?.blend === blend
|
||||
if (state && resource?.blend === blend) lastColor = state.colorHex
|
||||
attachments.push(visible ? `image_${resource!.id}_${state!.imageFrameIndex ?? 0}` : null)
|
||||
colors.push(rgbaValues(lastColor, visible ? state!.alpha : 0))
|
||||
}
|
||||
const blendResources = system.config.imageResources.filter((item) => item.blend === blend)
|
||||
const requestedModes = blendResources
|
||||
.filter((item) => item.independentAlpha === 'curve')
|
||||
.map((item) => resolvedTimelineCurve(item.alphaCurveMode, settings))
|
||||
const rgbaCurveMode = requestedModes.length
|
||||
? (requestedModes.includes('bezier') ? 'bezier' : 'linear')
|
||||
: resolvedTimelineCurve(system.config.alphaMode === 'curve' ? system.config.curveMode : undefined, settings)
|
||||
addSlotAnimation(animation, `${track.name}_image_${blend}`, attachments, colors, fps, settings, rgbaCurveMode)
|
||||
if (attachments.some(Boolean)) hasContent = true
|
||||
}
|
||||
|
||||
if (system.config.trail) for (const resource of system.config.trailResources) {
|
||||
const count = Math.max(2, Math.min(16, Math.round(resource.boneCount || 3)))
|
||||
const trailStates = states.map((state) => state?.trailState?.resourceId === resource.id ? state.trailState : undefined)
|
||||
const firstTrailState = trailStates.find((trail) => !!trail)
|
||||
if (!firstTrailState) continue
|
||||
const trailBoundaries = optionalStateBoundaryFrames(trailStates)
|
||||
const referenceWidth = resource.widthSizeMode === 'random'
|
||||
? Math.max(1, Number(resource.widthMin) || 0, Number(resource.widthMax) || 0)
|
||||
: Math.max(0.001, Number(resource.width) || 100)
|
||||
// 加权 Mesh 的骨骼和附件属于永久对象池,不能随粒子死亡而增删。
|
||||
// 出生前使用首次有效姿态,死亡后保持最近姿态;整组骨骼同帧缩放控制显隐。
|
||||
let heldTrailState = firstTrailState
|
||||
const rigStates = trailStates.map((trail) => {
|
||||
if (trail) heldTrailState = trail
|
||||
return heldTrailState
|
||||
})
|
||||
for (let boneIndex = 0; boneIndex < count; boneIndex++) {
|
||||
const samplesTranslate: NumericSample[] = [], samplesRotate: NumericSample[] = [], samplesScale: NumericSample[] = []
|
||||
const boneBoundaries = new Set(trailBoundaries)
|
||||
const shapeScale = trailShapeAt(resource, boneIndex / Math.max(1, count - 1))
|
||||
rigStates.forEach((trail, frame) => {
|
||||
const bone = trail?.bones[Math.min(trail.bones.length - 1, boneIndex)]
|
||||
if (!bone) return
|
||||
const world = { x: bone.worldX ?? bone.x, y: bone.worldY ?? bone.y, rotation: bone.worldRotation ?? -bone.rotation }
|
||||
// 所有拖尾骨骼均直接挂在粒子系统骨骼下,动画写入独立世界姿态,
|
||||
// 避免骨骼链带来的旋转、缩放和位置累计误差。
|
||||
samplesTranslate.push({ frame, values: [world.x, world.y] })
|
||||
samplesRotate.push({ frame, values: [world.rotation] })
|
||||
samplesScale.push({
|
||||
frame,
|
||||
// 假死只修改插槽透明度;骨骼缩放始终保留,用于宽度和形状曲线。
|
||||
values: [1, Math.max(0, trail.width / referenceWidth) * shapeScale],
|
||||
})
|
||||
})
|
||||
const name = `${track.name}_trail_${resource.id}_${boneIndex}`
|
||||
const timeline: SpineJsonMap = {}
|
||||
const translate = numericTimeline(samplesTranslate, fps, ['x', 'y'], [TRAIL_POSITION_TOLERANCE, TRAIL_POSITION_TOLERANCE], settings, boneBoundaries)
|
||||
const rotate = numericTimeline(samplesRotate, fps, ['value'], [ROTATION_TOLERANCE], settings, boneBoundaries)
|
||||
const scale = numericTimeline(samplesScale, fps, ['x', 'y'], [SCALE_TOLERANCE, SCALE_TOLERANCE], settings, boneBoundaries)
|
||||
if (translate.length) timeline.translate = translate
|
||||
if (rotate.length) timeline.rotate = rotate
|
||||
if (scale.length) timeline.scale = scale
|
||||
if (Object.keys(timeline).length) animation.bones[name] = timeline
|
||||
}
|
||||
// Mesh 始终绑定在插槽上,不再用 null 附件模拟死亡,避免拓扑在时间轴上消失/重建。
|
||||
const attachments = trailStates.map(() => `trail_${resource.id}`)
|
||||
let lastTrailColor = firstTrailState.colorHex
|
||||
const colors = trailStates.map((trail) => {
|
||||
if (trail) {
|
||||
lastTrailColor = trail.colorHex
|
||||
}
|
||||
// 整组拖尾骨骼同生同死,因此可由唯一插槽统一透明假死。
|
||||
return rgbaValues(lastTrailColor, trail ? trail.alpha : 0)
|
||||
})
|
||||
addSlotAnimation(animation, `${track.name}_trail_${resource.id}`, attachments, colors, fps, settings)
|
||||
hasContent = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hasContent || !settings.excludeEmptyAnimations) document.animations[animationName] = animation
|
||||
}
|
||||
|
||||
if (!Object.keys(document.animations).length && !settings.excludeEmptyAnimations) document.animations.animation = { bones: {}, slots: {} }
|
||||
const text = JSON.stringify(document, null, 2)
|
||||
return { json: document, text, warnings, particleSystemCount: visibleSystems.length, animationCount: Object.keys(document.animations).length, boneCount: document.bones.length }
|
||||
}
|
||||
|
||||
export function downloadSpineJson(result: SpineExportResult, fileName: string) {
|
||||
const blob = new Blob([result.text], { type: 'application/json;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = /\.json$/i.test(fileName) ? fileName : `${fileName || 'SpineParticle'}.json`
|
||||
anchor.style.display = 'none'
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
anchor.remove()
|
||||
// 给浏览器足够时间接管 Blob,避免立即释放导致下载被静默取消。
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export type SpineJsonMap = Record<string, any>
|
||||
|
||||
export interface SpineJsonDocument {
|
||||
skeleton: { hash: string; spine: string; x: number; y: number; width: number; height: number; images: string; fps?: number }
|
||||
bones: SpineJsonMap[]
|
||||
slots: SpineJsonMap[]
|
||||
skins: SpineJsonMap[]
|
||||
animations: Record<string, SpineJsonMap>
|
||||
}
|
||||
|
||||
export interface SpineExportWarning {
|
||||
code: 'missing-image' | 'missing-frame' | 'empty-cache' | 'invalid-value'
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface SpineExportResult {
|
||||
json: SpineJsonDocument
|
||||
text: string
|
||||
warnings: SpineExportWarning[]
|
||||
particleSystemCount: number
|
||||
animationCount: number
|
||||
boneCount: number
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
BoundingBoxAttachment,
|
||||
ClippingAttachment,
|
||||
MeshAttachment,
|
||||
PathAttachment,
|
||||
PointAttachment,
|
||||
RegionAttachment,
|
||||
SkeletonJson,
|
||||
type AttachmentLoader,
|
||||
type Skin,
|
||||
} from '@esotericsoftware/spine-core'
|
||||
|
||||
class ExportValidationAttachmentLoader implements AttachmentLoader {
|
||||
newRegionAttachment(_skin: Skin, name: string, path: string, _sequence: any) { return new RegionAttachment(name, path) }
|
||||
newMeshAttachment(_skin: Skin, name: string, path: string, _sequence: any) { return new MeshAttachment(name, path) }
|
||||
newBoundingBoxAttachment(_skin: Skin, name: string) { return new BoundingBoxAttachment(name) }
|
||||
newPathAttachment(_skin: Skin, name: string) { return new PathAttachment(name) }
|
||||
newPointAttachment(_skin: Skin, name: string) { return new PointAttachment(name) }
|
||||
newClippingAttachment(_skin: Skin, name: string) { return new ClippingAttachment(name) }
|
||||
}
|
||||
|
||||
const VALID_INHERIT = new Set(['normal', 'onlyTranslation', 'noRotationOrReflection', 'noScale', 'noScaleOrReflection'])
|
||||
|
||||
function validateMesh(mesh: Record<string, any>, attachmentName: string, boneCount: number) {
|
||||
const uvs = mesh.uvs
|
||||
const vertices = mesh.vertices
|
||||
const triangles = mesh.triangles
|
||||
if (!Array.isArray(uvs) || !uvs.length || uvs.length % 2) throw new Error(`拖尾网格“${attachmentName}”的 UV 数据无效`)
|
||||
if (!Array.isArray(vertices) || !vertices.length) throw new Error(`拖尾网格“${attachmentName}”缺少顶点数据`)
|
||||
const vertexCount = uvs.length / 2
|
||||
if (!Array.isArray(triangles) || triangles.some((index) => !Number.isInteger(index) || index < 0 || index >= vertexCount)) {
|
||||
throw new Error(`拖尾网格“${attachmentName}”的三角形索引越界`)
|
||||
}
|
||||
const hull = Number(mesh.hull) || 0
|
||||
if (!Number.isInteger(hull) || hull < 0 || hull > vertexCount) throw new Error(`拖尾网格“${attachmentName}”的外轮廓数量无效`)
|
||||
if (Array.isArray(mesh.edges) && mesh.edges.some((index) => !Number.isInteger(index) || index < 0 || index >= vertexCount * 2 || index % 2)) {
|
||||
throw new Error(`拖尾网格“${attachmentName}”的外轮廓边索引越界`)
|
||||
}
|
||||
// 未加权 Mesh 每个顶点只有 x/y;拖尾必须是逐顶点的骨骼权重编码。
|
||||
if (vertices.length === uvs.length) return
|
||||
let offset = 0
|
||||
let decodedVertices = 0
|
||||
while (offset < vertices.length) {
|
||||
const influences = vertices[offset++]
|
||||
if (!Number.isInteger(influences) || influences < 1) throw new Error(`拖尾网格“${attachmentName}”包含无效骨骼权重数量`)
|
||||
let weightSum = 0
|
||||
for (let influence = 0; influence < influences; influence++) {
|
||||
const boneIndex = vertices[offset++]
|
||||
const x = vertices[offset++]
|
||||
const y = vertices[offset++]
|
||||
const weight = vertices[offset++]
|
||||
if (!Number.isInteger(boneIndex) || boneIndex < 0 || boneIndex >= boneCount) throw new Error(`拖尾网格“${attachmentName}”引用了不存在的骨骼`)
|
||||
if (![x, y, weight].every(Number.isFinite) || weight < 0) throw new Error(`拖尾网格“${attachmentName}”包含无效权重数据`)
|
||||
weightSum += weight
|
||||
}
|
||||
if (Math.abs(weightSum - 1) > 0.001) throw new Error(`拖尾网格“${attachmentName}”的顶点权重之和不为 1`)
|
||||
decodedVertices++
|
||||
}
|
||||
if (offset !== vertices.length || decodedVertices !== vertexCount) throw new Error(`拖尾网格“${attachmentName}”的顶点数量与 UV 不一致`)
|
||||
}
|
||||
|
||||
function validateEditorImportStructure(root: Record<string, any>) {
|
||||
const bones = Array.isArray(root.bones) ? root.bones : []
|
||||
const boneByName = new Map(bones.map((bone) => [String(bone.name || ''), bone]))
|
||||
const weightedMeshSlots = new Set<string>()
|
||||
for (const bone of bones) {
|
||||
if (bone.inherit != null && !VALID_INHERIT.has(bone.inherit)) throw new Error(`骨骼“${bone.name || ''}”的 inherit 值“${bone.inherit}”不符合 Spine 4.2 JSON 格式`)
|
||||
if (/_particle_\d+_trail_/.test(String(bone.name || ''))) {
|
||||
const parent = boneByName.get(String(bone.parent || ''))
|
||||
if (!parent || /_particle_\d+_trail_/.test(String(parent.name || ''))) {
|
||||
throw new Error(`拖尾骨骼“${bone.name || ''}”必须直接挂在粒子系统骨骼下,不能组成骨骼链`)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const skin of Array.isArray(root.skins) ? root.skins : []) {
|
||||
const attachments = skin?.attachments || {}
|
||||
for (const slotName of Object.keys(attachments)) {
|
||||
for (const attachmentName of Object.keys(attachments[slotName] || {})) {
|
||||
const attachment = attachments[slotName][attachmentName]
|
||||
if (attachment?.type === 'mesh') {
|
||||
validateMesh(attachment, attachmentName, bones.length)
|
||||
if (attachment.vertices?.length !== attachment.uvs?.length) weightedMeshSlots.add(slotName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const animation of Object.values(root.animations || {}) as Array<Record<string, any>>) {
|
||||
for (const slotName of weightedMeshSlots) {
|
||||
const attachmentKeys = animation?.slots?.[slotName]?.attachment
|
||||
if (Array.isArray(attachmentKeys) && attachmentKeys.some((key) => !key?.name)) {
|
||||
throw new Error(`加权拖尾插槽“${slotName}”不能使用空附件,未出现或死亡阶段应将对应骨骼缩放设为 0`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 下载前使用项目内置 Spine 4.2 Runtime 做一次结构解析,提前拦截无效 JSON。 */
|
||||
export function validateSpineJson(text: string) {
|
||||
const root = JSON.parse(text)
|
||||
validateEditorImportStructure(root)
|
||||
const reader = new SkeletonJson(new ExportValidationAttachmentLoader())
|
||||
const data = reader.readSkeletonData(root)
|
||||
if (!data.bones.length) throw new Error('导出结果缺少骨骼')
|
||||
return { bones: data.bones.length, slots: data.slots.length, animations: data.animations.length }
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
export interface NumericSample {
|
||||
frame: number
|
||||
values: number[]
|
||||
}
|
||||
|
||||
export interface BezierSegmentControls {
|
||||
/** 每个维度的第一个、第二个控制值。控制时间固定在区间 1/3 与 2/3。 */
|
||||
values: Array<[number, number]>
|
||||
}
|
||||
|
||||
export interface ReducedBezierSamples {
|
||||
samples: NumericSample[]
|
||||
controls: BezierSegmentControls[]
|
||||
}
|
||||
|
||||
function interpolationError(sample: NumericSample, start: NumericSample, end: NumericSample, tolerances: number[]) {
|
||||
const span = end.frame - start.frame
|
||||
const amount = span <= 0 ? 0 : (sample.frame - start.frame) / span
|
||||
let error = 0
|
||||
for (let i = 0; i < sample.values.length; i++) {
|
||||
const expected = start.values[i] + (end.values[i] - start.values[i]) * amount
|
||||
error = Math.max(error, Math.abs(sample.values[i] - expected) / Math.max(1e-9, tolerances[i] ?? tolerances[0] ?? 1e-3))
|
||||
}
|
||||
return error
|
||||
}
|
||||
|
||||
/** 保留误差最大的点并递归拆分,确保删除关键帧后仍在给定误差内。 */
|
||||
export function reduceNumericSamples(samples: NumericSample[], tolerances: number[], forcedFrames: Set<number> = new Set()) {
|
||||
if (samples.length <= 2) return samples.slice()
|
||||
const retained = new Set<number>([0, samples.length - 1])
|
||||
for (let i = 1; i < samples.length - 1; i++) if (forcedFrames.has(samples[i].frame)) retained.add(i)
|
||||
const anchors = () => [...retained].sort((a, b) => a - b)
|
||||
let changed = true
|
||||
while (changed) {
|
||||
changed = false
|
||||
const ordered = anchors()
|
||||
for (let segment = 0; segment < ordered.length - 1; segment++) {
|
||||
const startIndex = ordered[segment]
|
||||
const endIndex = ordered[segment + 1]
|
||||
let worstIndex = -1
|
||||
let worstError = 1
|
||||
for (let i = startIndex + 1; i < endIndex; i++) {
|
||||
const error = interpolationError(samples[i], samples[startIndex], samples[endIndex], tolerances)
|
||||
if (error > worstError) { worstError = error; worstIndex = i }
|
||||
}
|
||||
if (worstIndex >= 0) { retained.add(worstIndex); changed = true }
|
||||
}
|
||||
}
|
||||
return anchors().map((index) => samples[index])
|
||||
}
|
||||
|
||||
function endpointDerivative(samples: NumericSample[], start: number, end: number, dimension: number, atStart: boolean) {
|
||||
const count = end - start + 1
|
||||
if (count < 2) return 0
|
||||
const first = atStart ? start : end
|
||||
const second = atStart ? start + 1 : end - 1
|
||||
const third = atStart ? start + 2 : end - 2
|
||||
const h = Math.abs(samples[second].frame - samples[first].frame)
|
||||
if (count >= 3 && h > 0 && Math.abs(Math.abs(samples[third].frame - samples[second].frame) - h) < 1e-6) {
|
||||
const v0 = samples[first].values[dimension]
|
||||
const v1 = samples[second].values[dimension]
|
||||
const v2 = samples[third].values[dimension]
|
||||
return atStart ? (-3 * v0 + 4 * v1 - v2) / (2 * h) : (3 * v0 - 4 * v1 + v2) / (2 * h)
|
||||
}
|
||||
const frameSpan = samples[second].frame - samples[first].frame
|
||||
return frameSpan ? (samples[second].values[dimension] - samples[first].values[dimension]) / frameSpan : 0
|
||||
}
|
||||
|
||||
function bezierControls(samples: NumericSample[], start: number, end: number): BezierSegmentControls {
|
||||
const span = samples[end].frame - samples[start].frame
|
||||
const values: Array<[number, number]> = []
|
||||
for (let dimension = 0; dimension < samples[start].values.length; dimension++) {
|
||||
const startValue = samples[start].values[dimension]
|
||||
const endValue = samples[end].values[dimension]
|
||||
const min = Math.min(...samples.slice(start, end + 1).map((sample) => sample.values[dimension]))
|
||||
const max = Math.max(...samples.slice(start, end + 1).map((sample) => sample.values[dimension]))
|
||||
const first = startValue + endpointDerivative(samples, start, end, dimension, true) * span / 3
|
||||
const second = endValue - endpointDerivative(samples, start, end, dimension, false) * span / 3
|
||||
// 控制值限制在该段实际采样范围内,避免帧间过冲。
|
||||
values.push([Math.max(min, Math.min(max, first)), Math.max(min, Math.min(max, second))])
|
||||
}
|
||||
return { values }
|
||||
}
|
||||
|
||||
function cubicAt(start: number, control1: number, control2: number, end: number, amount: number) {
|
||||
const inverse = 1 - amount
|
||||
return inverse * inverse * inverse * start
|
||||
+ 3 * inverse * inverse * amount * control1
|
||||
+ 3 * inverse * amount * amount * control2
|
||||
+ amount * amount * amount * end
|
||||
}
|
||||
|
||||
function bezierInterpolationError(sample: NumericSample, start: NumericSample, end: NumericSample, controls: BezierSegmentControls, tolerances: number[]) {
|
||||
const span = end.frame - start.frame
|
||||
const amount = span <= 0 ? 0 : (sample.frame - start.frame) / span
|
||||
let error = 0
|
||||
for (let dimension = 0; dimension < sample.values.length; dimension++) {
|
||||
const control = controls.values[dimension]
|
||||
const expected = cubicAt(start.values[dimension], control[0], control[1], end.values[dimension], amount)
|
||||
error = Math.max(error, Math.abs(sample.values[dimension] - expected) / Math.max(1e-9, tolerances[dimension] ?? tolerances[0] ?? 1e-3))
|
||||
}
|
||||
return error
|
||||
}
|
||||
|
||||
/** 使用三次贝塞尔拟合连续采样,并以原始逐帧数据验证每一段误差。 */
|
||||
export function reduceBezierSamples(samples: NumericSample[], tolerances: number[], forcedFrames: Set<number> = new Set()): ReducedBezierSamples {
|
||||
if (samples.length <= 1) return { samples: samples.slice(), controls: [] }
|
||||
const retained = new Set<number>([0, samples.length - 1])
|
||||
for (let index = 1; index < samples.length - 1; index++) if (forcedFrames.has(samples[index].frame)) retained.add(index)
|
||||
const anchors = () => [...retained].sort((a, b) => a - b)
|
||||
let changed = true
|
||||
while (changed) {
|
||||
changed = false
|
||||
const ordered = anchors()
|
||||
for (let segment = 0; segment < ordered.length - 1; segment++) {
|
||||
const start = ordered[segment], end = ordered[segment + 1]
|
||||
if (end - start <= 1) continue
|
||||
const controls = bezierControls(samples, start, end)
|
||||
let worstIndex = -1, worstError = 1
|
||||
for (let index = start + 1; index < end; index++) {
|
||||
const error = bezierInterpolationError(samples[index], samples[start], samples[end], controls, tolerances)
|
||||
if (error > worstError) { worstError = error; worstIndex = index }
|
||||
}
|
||||
if (worstIndex >= 0) { retained.add(worstIndex); changed = true }
|
||||
}
|
||||
}
|
||||
const ordered = anchors()
|
||||
return {
|
||||
samples: ordered.map((index) => samples[index]),
|
||||
controls: ordered.slice(0, -1).map((start, index) => bezierControls(samples, start, ordered[index + 1])),
|
||||
}
|
||||
}
|
||||
|
||||
export function unwrapDegrees(values: number[]) {
|
||||
if (!values.length) return []
|
||||
const result = [values[0]]
|
||||
for (let i = 1; i < values.length; i++) {
|
||||
let value = values[i]
|
||||
while (value - result[i - 1] > 180) value -= 360
|
||||
while (value - result[i - 1] < -180) value += 360
|
||||
result.push(value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function roundSpine(value: number) {
|
||||
if (!Number.isFinite(value)) return 0
|
||||
const rounded = Math.round(value * 100000) / 100000
|
||||
return Object.is(rounded, -0) ? 0 : rounded
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { EmitterConfig, TrailImageResource } from '../core/particleEmitter'
|
||||
import { roundSpine } from './spineKeyframeReducer'
|
||||
|
||||
export interface WeightedTrailMesh {
|
||||
type: 'mesh'
|
||||
path: string
|
||||
uvs: number[]
|
||||
triangles: number[]
|
||||
vertices: number[]
|
||||
hull: number
|
||||
edges: number[]
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
/** 生成由同级拖尾骨骼加权驱动的网格。每列至多受相邻两根骨骼影响。 */
|
||||
export function buildWeightedTrailMesh(config: EmitterConfig, resource: TrailImageResource, boneIndices: number[], path: string): WeightedTrailMesh {
|
||||
const rows = Math.max(2, Math.min(16, Math.round(Number(config.trailGridRows) || 2)))
|
||||
const cols = Math.max(2, Math.min(32, Math.round(Number(config.trailGridCols) || 2)))
|
||||
const count = Math.max(2, boneIndices.length)
|
||||
const length = resource.lengthMode === 'random'
|
||||
? Math.max(1, Number(resource.lengthMin) || 0, Number(resource.lengthMax) || 0)
|
||||
: Math.max(0.001, Number(resource.length) || 100)
|
||||
const width = resource.widthSizeMode === 'random'
|
||||
? Math.max(1, Number(resource.widthMin) || 0, Number(resource.widthMax) || 0)
|
||||
: Math.max(0.001, Number(resource.width) || 100)
|
||||
const angle = (Number(resource.rotation) || 0) * Math.PI / 180
|
||||
const cos = Math.cos(angle), sin = Math.sin(angle)
|
||||
const uvs: number[] = []
|
||||
const triangles: number[] = []
|
||||
const vertices: number[] = []
|
||||
const edges: number[] = []
|
||||
|
||||
// Spine 的 hull 表示 vertices/uvs 开头连续存放的外轮廓顶点数量。
|
||||
// 先按顺时针写入四条边,再追加内部网格点,保证重新导入编辑器时网格拓扑有效。
|
||||
const orderedGrid: Array<{ col: number; row: number }> = []
|
||||
for (let col = 0; col < cols; col++) orderedGrid.push({ col, row: 0 })
|
||||
for (let row = 1; row < rows; row++) orderedGrid.push({ col: cols - 1, row })
|
||||
for (let col = cols - 2; col >= 0; col--) orderedGrid.push({ col, row: rows - 1 })
|
||||
for (let row = rows - 2; row >= 1; row--) orderedGrid.push({ col: 0, row })
|
||||
const hull = orderedGrid.length
|
||||
for (let col = 1; col < cols - 1; col++) {
|
||||
for (let row = 1; row < rows - 1; row++) orderedGrid.push({ col, row })
|
||||
}
|
||||
const vertexIndices = new Map<string, number>()
|
||||
|
||||
for (let vertexIndex = 0; vertexIndex < orderedGrid.length; vertexIndex++) {
|
||||
const { col, row } = orderedGrid[vertexIndex]
|
||||
vertexIndices.set(`${col}:${row}`, vertexIndex)
|
||||
const columnT = col / (cols - 1)
|
||||
const position = columnT * (count - 1)
|
||||
const boneA = Math.min(count - 1, Math.floor(position))
|
||||
const boneB = Math.min(count - 1, boneA + 1)
|
||||
const blend = position - boneA
|
||||
const rowT = row / (rows - 1)
|
||||
const u = 0.5 + cos * (columnT - 0.5) - sin * (rowT - 0.5)
|
||||
const v = 0.5 + sin * (columnT - 0.5) + cos * (rowT - 0.5)
|
||||
uvs.push(roundSpine(u), roundSpine(v))
|
||||
const setupY = (0.5 - rowT) * width
|
||||
if (boneA === boneB || blend < 1e-6) {
|
||||
vertices.push(1, boneIndices[boneA], 0, roundSpine(setupY), 1)
|
||||
} else {
|
||||
vertices.push(
|
||||
2,
|
||||
boneIndices[boneA], 0, roundSpine(setupY), roundSpine(1 - blend),
|
||||
boneIndices[boneB], 0, roundSpine(setupY), roundSpine(blend),
|
||||
)
|
||||
}
|
||||
}
|
||||
for (let col = 0; col < cols - 1; col++) {
|
||||
for (let row = 0; row < rows - 1; row++) {
|
||||
const a = vertexIndices.get(`${col}:${row}`)!
|
||||
const b = vertexIndices.get(`${col + 1}:${row}`)!
|
||||
const c = vertexIndices.get(`${col}:${row + 1}`)!
|
||||
const d = vertexIndices.get(`${col + 1}:${row + 1}`)!
|
||||
triangles.push(a, b, c, b, d, c)
|
||||
}
|
||||
}
|
||||
for (let index = 0; index < hull; index++) edges.push(index * 2, ((index + 1) % hull) * 2)
|
||||
return { type: 'mesh', path, uvs, triangles, vertices, hull, edges, width: length, height: width }
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { EmitterConfig, defaultConfig, ParticleState, type SceneCollider } from
|
||||
import type { SpineSceneObject } from '../spine/spineTypes'
|
||||
import { releaseSpineAsset, retainSpineAsset } from '../spine/spineAssetRegistry'
|
||||
import { cloneEditorValue } from '../editor/editorClone'
|
||||
import { defaultSpineExportSettings, type SpineExportSettings } from '../export/spineExportSettings'
|
||||
|
||||
export interface ParticleSystem {
|
||||
id: number
|
||||
@@ -24,7 +25,7 @@ export interface CollisionBody extends SceneCollider {
|
||||
followDuration: number
|
||||
followPathRotation: boolean
|
||||
followCurveMode: '跟随导出' | '线性' | '贝塞尔'
|
||||
followCurve: Array<{ x: number; y: number }>
|
||||
followCurve: Array<{ x: number; y: number; inX?: number; inY?: number; outX?: number; outY?: number }>
|
||||
}
|
||||
|
||||
export interface ScenePath {
|
||||
@@ -73,8 +74,14 @@ export interface SettingsState {
|
||||
backgroundImageData: string
|
||||
showEmitter: boolean
|
||||
showBones: boolean
|
||||
/** 骨骼调试点的屏幕固定尺寸倍率。 */
|
||||
bonePointDrawSize: number
|
||||
/** 骨骼局部方向轴的屏幕固定尺寸倍率。 */
|
||||
boneAxisDrawSize: number
|
||||
showSkinMesh: boolean
|
||||
showGrid: boolean
|
||||
/** Spine JSON 导出的全局参数。 */
|
||||
exportSettings: SpineExportSettings
|
||||
}
|
||||
|
||||
export interface TimelineState {
|
||||
@@ -136,8 +143,11 @@ export const useParticleStore = defineStore('particle', {
|
||||
backgroundImageData: '',
|
||||
showEmitter: true,
|
||||
showBones: false,
|
||||
bonePointDrawSize: 1,
|
||||
boneAxisDrawSize: 2,
|
||||
showSkinMesh: false,
|
||||
showGrid: true,
|
||||
exportSettings: defaultSpineExportSettings(),
|
||||
} as SettingsState,
|
||||
}),
|
||||
getters: {
|
||||
|
||||
@@ -8,8 +8,15 @@
|
||||
<line v-for="g in [0.25,0.5,0.75]" :key="'h'+g" x1="0" :y1="py(g)" :x2="px(1)" :y2="py(g)" />
|
||||
</g>
|
||||
<rect :x="px(0)" :y="py(1)" :width="px(1)-px(0)" :height="py(0)-py(1)" class="ce-frame" fill="none" />
|
||||
<!-- 折线 -->
|
||||
<polyline :points="polyPoints" class="ce-line" fill="none" />
|
||||
<path :d="curvePath" class="ce-line" fill="none" />
|
||||
<g v-if="mode === 'bezier'" class="ce-handles">
|
||||
<template v-for="(pt, i) in pts" :key="'handles-'+i">
|
||||
<line v-if="i > 0" :x1="px(pt.x)" :y1="py(pt.y)" :x2="px(inputHandle(i).x)" :y2="py(inputHandle(i).y)" />
|
||||
<circle v-if="i > 0" :cx="px(inputHandle(i).x)" :cy="py(inputHandle(i).y)" r="4" @pointerdown="onHandleDown($event, i, 'in')" />
|
||||
<line v-if="i < pts.length - 1" :x1="px(pt.x)" :y1="py(pt.y)" :x2="px(outputHandle(i).x)" :y2="py(outputHandle(i).y)" />
|
||||
<circle v-if="i < pts.length - 1" :cx="px(outputHandle(i).x)" :cy="py(outputHandle(i).y)" r="4" @pointerdown="onHandleDown($event, i, 'out')" />
|
||||
</template>
|
||||
</g>
|
||||
<!-- 节点:可见圈 r=5 + 命中圈 r=9 -->
|
||||
<g v-for="(pt,i) in pts" :key="i">
|
||||
<circle :cx="px(pt.x)" :cy="py(pt.y)" r="5" class="ce-node" />
|
||||
@@ -17,7 +24,7 @@
|
||||
@pointerdown="onNodeDown($event,i)" @dblclick="onNodeDbl(i,$event)" />
|
||||
</g>
|
||||
</svg>
|
||||
<div class="ce-nav"><span class="ce-tip">双击空白新建·双击节点删除·拖动节点调整</span></div>
|
||||
<div class="ce-nav"><span class="ce-tip">{{ mode === 'bezier' ? '拖动方形手柄调整弧度·双击空白新建·双击节点删除' : '双击空白新建·双击节点删除·拖动节点调整' }}</span></div>
|
||||
</div>
|
||||
<button class="ce-reset" type="button" title="恢复默认曲线" @click="resetCurve">重置</button>
|
||||
</div>
|
||||
@@ -26,15 +33,17 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
type CurvePoint = { x: number; y: number }
|
||||
type CurvePoint = { x: number; y: number; inX?: number; inY?: number; outX?: number; outY?: number }
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: CurvePoint[]
|
||||
defaultValue?: CurvePoint[]
|
||||
mode?: 'linear' | 'bezier'
|
||||
}>(), {
|
||||
defaultValue: () => [{ x: 0, y: 0 }, { x: 1, y: 1 }],
|
||||
mode: 'linear',
|
||||
})
|
||||
const emit = defineEmits<{ (e: 'update:modelValue', v: { x: number; y: number }[]): void }>()
|
||||
const emit = defineEmits<{ (e: 'update:modelValue', v: CurvePoint[]): void }>()
|
||||
|
||||
const svg = ref<SVGSVGElement | null>(null)
|
||||
const pad = 8 // 内边距(px),避免节点贴边
|
||||
@@ -54,17 +63,45 @@ function px(x: number) { return pad + x * (W.value - pad * 2) }
|
||||
// 归一化 y(0-1, y=1 在上) → 像素
|
||||
function py(y: number) { return pad + (1 - y) * (H.value - pad * 2) }
|
||||
|
||||
function normalized(): { x: number; y: number }[] {
|
||||
function normalized(): CurvePoint[] {
|
||||
return [...props.modelValue].sort((a, b) => a.x - b.x)
|
||||
}
|
||||
function set(v: { x: number; y: number }[]) { emit('update:modelValue', [...v].sort((a, b) => a.x - b.x)) }
|
||||
function set(v: CurvePoint[]) { emit('update:modelValue', [...v].sort((a, b) => a.x - b.x)) }
|
||||
|
||||
function resetCurve() {
|
||||
set(props.defaultValue.map((point) => ({ ...point })))
|
||||
}
|
||||
|
||||
const pts = computed(() => normalized())
|
||||
const polyPoints = computed(() => pts.value.map(p => `${px(p.x)},${py(p.y)}`).join(' '))
|
||||
|
||||
function defaultInputHandle(index: number) {
|
||||
const point = pts.value[index], previous = pts.value[Math.max(0, index - 1)]
|
||||
return { x: point.x - (point.x - previous.x) / 3, y: point.y - (point.y - previous.y) / 3 }
|
||||
}
|
||||
function defaultOutputHandle(index: number) {
|
||||
const point = pts.value[index], next = pts.value[Math.min(pts.value.length - 1, index + 1)]
|
||||
return { x: point.x + (next.x - point.x) / 3, y: point.y + (next.y - point.y) / 3 }
|
||||
}
|
||||
function inputHandle(index: number) {
|
||||
const point = pts.value[index], fallback = defaultInputHandle(index)
|
||||
return { x: point.x + (point.inX ?? fallback.x - point.x), y: point.y + (point.inY ?? fallback.y - point.y) }
|
||||
}
|
||||
function outputHandle(index: number) {
|
||||
const point = pts.value[index], fallback = defaultOutputHandle(index)
|
||||
return { x: point.x + (point.outX ?? fallback.x - point.x), y: point.y + (point.outY ?? fallback.y - point.y) }
|
||||
}
|
||||
const curvePath = computed(() => {
|
||||
if (!pts.value.length) return ''
|
||||
let path = `M ${px(pts.value[0].x)} ${py(pts.value[0].y)}`
|
||||
for (let index = 1; index < pts.value.length; index++) {
|
||||
const point = pts.value[index]
|
||||
if (props.mode === 'bezier') {
|
||||
const output = outputHandle(index - 1), input = inputHandle(index)
|
||||
path += ` C ${px(output.x)} ${py(output.y)} ${px(input.x)} ${py(input.y)} ${px(point.x)} ${py(point.y)}`
|
||||
} else path += ` L ${px(point.x)} ${py(point.y)}`
|
||||
}
|
||||
return path
|
||||
})
|
||||
|
||||
// 客户端坐标 → 归一化 (x:0-1 时间, y:0-1 透明度)
|
||||
function toNorm(clientX: number, clientY: number) {
|
||||
@@ -117,6 +154,28 @@ function onNodeDown(ev: PointerEvent, i: number) {
|
||||
window.addEventListener('pointerup', up)
|
||||
}
|
||||
|
||||
function onHandleDown(ev: PointerEvent, index: number, side: 'in' | 'out') {
|
||||
ev.stopPropagation()
|
||||
const move = (event: PointerEvent) => {
|
||||
const target = toNorm(event.clientX, event.clientY)
|
||||
const arr = normalized()
|
||||
const point = arr[index]
|
||||
const minimumX = side === 'in' ? arr[Math.max(0, index - 1)].x : point.x
|
||||
const maximumX = side === 'in' ? point.x : arr[Math.min(arr.length - 1, index + 1)].x
|
||||
const handleX = Math.min(maximumX, Math.max(minimumX, target.x))
|
||||
const dx = Math.round((handleX - point.x) * 100) / 100
|
||||
const dy = Math.round((target.y - point.y) * 100) / 100
|
||||
arr[index] = side === 'in' ? { ...point, inX: dx, inY: dy } : { ...point, outX: dx, outY: dy }
|
||||
set(arr)
|
||||
}
|
||||
const up = () => {
|
||||
window.removeEventListener('pointermove', move)
|
||||
window.removeEventListener('pointerup', up)
|
||||
}
|
||||
window.addEventListener('pointermove', move)
|
||||
window.addEventListener('pointerup', up)
|
||||
}
|
||||
|
||||
// 双击节点删除
|
||||
function onNodeDbl(i: number, ev?: MouseEvent) {
|
||||
ev?.stopPropagation()
|
||||
@@ -146,6 +205,9 @@ onUnmounted(() => { ro?.disconnect() })
|
||||
.ce-grid line { stroke: #1e1e30; stroke-width: 1; }
|
||||
.ce-frame { stroke: #2e2e44; stroke-width: 1; }
|
||||
.ce-line { stroke: #6ea0e8; stroke-width: 2; stroke-linejoin: round; }
|
||||
.ce-handles line { stroke: #8d96b5; stroke-width: 1; stroke-dasharray: 3 2; }
|
||||
.ce-handles circle { fill: #b7c2dd; stroke: #344d94; stroke-width: 1.2; cursor: grab; }
|
||||
.ce-handles circle:active { cursor: grabbing; }
|
||||
.ce-node { fill: #ffffff; stroke: #3a55aa; stroke-width: 1.6; pointer-events: none; }
|
||||
.ce-hit { fill: transparent; stroke: none; cursor: grab; }
|
||||
.ce-hit:active { cursor: grabbing; }
|
||||
|
||||
@@ -175,6 +175,7 @@
|
||||
<CurveEditor
|
||||
:model-value="activeCollider.followCurve || LINEAR_CURVE"
|
||||
:default-value="LINEAR_CURVE"
|
||||
:mode="resolvedCurveEditorMode(activeCollider.followCurveMode)"
|
||||
@update:model-value="activeCollider.followCurve = $event"
|
||||
/>
|
||||
</template>
|
||||
@@ -455,7 +456,7 @@
|
||||
</select>
|
||||
</label>
|
||||
<div class="curve-wrap">
|
||||
<CurveEditor v-model="sys.config.alphaCurve" :default-value="ALPHA_CURVE" />
|
||||
<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>
|
||||
@@ -518,7 +519,7 @@
|
||||
</select>
|
||||
</label>
|
||||
<div class="resource-alpha-curve">
|
||||
<CurveEditor v-model="resource.alphaCurve" :default-value="RESOURCE_ALPHA_CURVE" />
|
||||
<CurveEditor v-model="resource.alphaCurve" :default-value="RESOURCE_ALPHA_CURVE" :mode="resolvedCurveEditorMode(resource.alphaCurveMode)" />
|
||||
</div>
|
||||
</template>
|
||||
</ImageResourceCard>
|
||||
@@ -663,8 +664,29 @@
|
||||
<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="1000" :step="1" v-model="resource.length" />
|
||||
<NumSlider label="初始宽度" :min="0" :max="1000" :step="1" v-model="resource.width" />
|
||||
<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>
|
||||
@@ -721,7 +743,7 @@
|
||||
<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 class="modifier-hint">每个粒子按资源比例生成一组同级拖尾骨骼;随机长度与宽度在粒子出生时确定,相同种子可稳定复现</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modifier-item">
|
||||
@@ -791,20 +813,66 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 7. 导出设置(预留) -->
|
||||
<!-- 7. 导出设置:当前阶段只维护参数,后续接入 JSON 生成。 -->
|
||||
<div class="group">
|
||||
<div class="group-head" @click="toggle('export')">
|
||||
<span class="caret">{{ collapsed.export ? '▸' : '▾' }}</span><span>导出设置</span><span class="tag">预留</span>
|
||||
<span class="caret">{{ collapsed.export ? '▸' : '▾' }}</span><span>导出设置</span>
|
||||
<button class="group-reset" title="重置导出设置" @click.stop="resetModule('export')">重置</button>
|
||||
</div>
|
||||
<div class="group-body" v-show="!collapsed.export">
|
||||
<div class="placeholder">导出功能后续实现,已预留位置</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="4.2">Spine 4.2</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 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>
|
||||
</div>
|
||||
|
||||
<button type="button" class="export-spine-json" title="导出功能将在后续阶段实现">导出Spine Json</button>
|
||||
<button type="button" class="export-spine-json" :disabled="exportBusy" :title="exportBusy ? '正在重新计算并烘焙动画' : '导出可重新导入 Spine 4.2 的 JSON'" @click="exportSpineJson">
|
||||
{{ exportBusy ? '正在准备导出…' : '导出Spine Json' }}
|
||||
</button>
|
||||
<div v-if="exportMessage" class="export-message" :class="{ error: exportError }">{{ exportMessage }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -812,6 +880,7 @@
|
||||
import { computed, markRaw, onMounted, reactive, ref, 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'
|
||||
@@ -820,14 +889,27 @@ import ColorGradientEditor from './ColorGradientEditor.vue'
|
||||
import ImageResourceCard from './ImageResourceCard.vue'
|
||||
import SpineObjectPanel from '../spine/SpineObjectPanel.vue'
|
||||
import { downloadEditorConfig, loadEditorConfigFile, loadEditorConfigText } from '../editor/editorConfig'
|
||||
import { defaultSpineExportSettings, ensureSpineExportSettings } from '../export/spineExportSettings'
|
||||
import { buildSpineJson, downloadSpineJson } from '../export/spineJsonExporter'
|
||||
import { validateSpineJson } from '../export/spineJsonValidator'
|
||||
|
||||
const store = useParticleStore()
|
||||
store.settings.exportSettings = ensureSpineExportSettings(store.settings.exportSettings)
|
||||
const exportSettings = computed(() => store.settings.exportSettings)
|
||||
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)
|
||||
const exportBusy = ref(false)
|
||||
const exportMessage = ref('')
|
||||
const exportError = ref(false)
|
||||
|
||||
function resolvedCurveEditorMode(mode: '跟随导出' | '线性' | '贝塞尔') {
|
||||
if (mode === '跟随导出') return exportSettings.value.keyframeCurve === 'bezier' ? 'bezier' : 'linear'
|
||||
return mode === '贝塞尔' ? 'bezier' : 'linear'
|
||||
}
|
||||
// 默认场景始终有一个可编辑的粒子系统;热更新保留现有系统时不重复创建。
|
||||
if (!store.systems.length) store.addSystem()
|
||||
|
||||
@@ -890,6 +972,46 @@ async function loadSelectedPreset() {
|
||||
|
||||
onMounted(refreshPresets)
|
||||
|
||||
function nextPaint() {
|
||||
return new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
||||
}
|
||||
|
||||
async function prepareExportFrames() {
|
||||
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('重新计算动画超时,请确认页面仍在运行后重试')
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
}
|
||||
}
|
||||
|
||||
async function exportSpineJson() {
|
||||
if (exportBusy.value) return
|
||||
exportBusy.value = true
|
||||
exportError.value = false
|
||||
exportMessage.value = '正在重新计算粒子动画…'
|
||||
try {
|
||||
await prepareExportFrames()
|
||||
const result = buildSpineJson({ systems: store.systems as unknown as ParticleSystem[], timeline: store.timeline, settings: exportSettings.value })
|
||||
validateSpineJson(result.text)
|
||||
downloadSpineJson(result, exportSettings.value.fileName)
|
||||
const warning = result.warnings.length ? `;${result.warnings.length} 条资源提示(缺图仍已导出)` : ''
|
||||
exportMessage.value = `已导出 ${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
|
||||
}
|
||||
}
|
||||
|
||||
const activeIdx = computed(() => {
|
||||
const i = store.systems.findIndex((s) => s.id === store.activeId)
|
||||
return i >= 0 ? i : 0
|
||||
@@ -993,7 +1115,7 @@ const collapsed = reactive<Record<string, boolean>>({
|
||||
attr: true,
|
||||
look: true,
|
||||
mods: true,
|
||||
export: true,
|
||||
export: false,
|
||||
})
|
||||
function toggle(key: string) { collapsed[key] = !collapsed[key] }
|
||||
|
||||
@@ -1143,12 +1265,20 @@ const MODULE_RESET_KEYS: Record<Exclude<ResetModule, 'export'>, string[]> = {
|
||||
|
||||
function resetModule(module: ResetModule) {
|
||||
const config = sys.value?.config as any
|
||||
if (!config || module === 'export') return
|
||||
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() }
|
||||
@@ -1260,7 +1390,7 @@ function addTrailResource() {
|
||||
id: nextId,
|
||||
texture: null,
|
||||
textureName: 'trail',
|
||||
previewUrl: '/trails/trail.png',
|
||||
previewUrl: '/images/trail.png',
|
||||
weight: 100,
|
||||
locked: false,
|
||||
rotation: 0,
|
||||
@@ -1272,8 +1402,15 @@ function addTrailResource() {
|
||||
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 })),
|
||||
@@ -1329,15 +1466,22 @@ 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('/trails/trail.png')) as any
|
||||
resource.previewUrl = '/trails/trail.png'
|
||||
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 }))
|
||||
@@ -1429,7 +1573,7 @@ function addImageResource() {
|
||||
texture: config.texture,
|
||||
textureName: 'star',
|
||||
textureFolder: '',
|
||||
previewUrl: '/particles/star.png',
|
||||
previewUrl: '/images/star.png',
|
||||
imageMode: 'fixed',
|
||||
sequencePlayback: 'loop-forward',
|
||||
sequenceFps: 30,
|
||||
@@ -1513,7 +1657,7 @@ function resetTexture(resourceId: number) {
|
||||
resource.texture = config.texture
|
||||
resource.textureName = 'star'
|
||||
resource.textureFolder = ''
|
||||
resource.previewUrl = '/particles/star.png'
|
||||
resource.previewUrl = '/images/star.png'
|
||||
resource.imageMode = 'fixed'
|
||||
resource.sequencePlayback = 'loop-forward'
|
||||
resource.sequenceFps = 30
|
||||
@@ -1744,8 +1888,22 @@ function toggleResourceLock(resourceId: number) {
|
||||
.mini-btn { background: #2e3a55; border: 1px solid #3a4a6a; color: #cdf; border-radius: 5px; padding: 3px 8px; font-size: 11px; cursor: pointer; }
|
||||
.mini-btn.danger { background: #4a2a2e; color: #fbb; }
|
||||
.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; }
|
||||
.export-spine-json { width: 100%; margin-top: 10px; padding: 9px 12px; 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-message { padding: 7px 5px 0; color: #70d8ae; font-size: 10px; line-height: 1.45; text-align: center; }
|
||||
.export-message.error { color: #ff858f; }
|
||||
/* 外观与资源 参考版式 */
|
||||
.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; }
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="help-btn" :class="{ on: showHelp }" title="查看使用说明" @click="openHelp">使用说明</button>
|
||||
<span class="zoom-pct">{{ Math.round(store.editor.viewScale * 100) }}%</span>
|
||||
|
||||
</div>
|
||||
@@ -79,6 +80,14 @@
|
||||
<div class="sp-section-title">画布辅助显示</div>
|
||||
<label class="sp-chk"><input type="checkbox" v-model="store.settings.showEmitter" />发射点</label>
|
||||
<label class="sp-chk"><input type="checkbox" v-model="store.settings.showBones" />骨骼连线</label>
|
||||
<template v-if="store.settings.showBones">
|
||||
<label class="sp-row"><span>骨骼点绘制大小</span>
|
||||
<input class="sp-num" type="number" min="0.1" max="10" step="0.1" v-model.number="store.settings.bonePointDrawSize" aria-label="骨骼点绘制大小" @change="normalizeBoneDebugSizes" />
|
||||
</label>
|
||||
<label class="sp-row"><span>坐标轴绘制大小</span>
|
||||
<input class="sp-num" type="number" min="0.1" max="10" step="0.1" v-model.number="store.settings.boneAxisDrawSize" aria-label="坐标轴绘制大小" @change="normalizeBoneDebugSizes" />
|
||||
</label>
|
||||
</template>
|
||||
<label class="sp-chk"><input type="checkbox" v-model="store.settings.showSkinMesh" />蒙皮网格</label>
|
||||
<div class="sp-divider"></div>
|
||||
<label class="sp-chk"><input type="checkbox" v-model="store.settings.tickEnabled" />启用刻度</label>
|
||||
@@ -99,6 +108,64 @@
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="showHelp" class="help-overlay" role="presentation" @click.self="showHelp = false">
|
||||
<section class="help-dialog" role="dialog" aria-modal="true" aria-labelledby="help-title">
|
||||
<header class="help-head">
|
||||
<div>
|
||||
<h2 id="help-title">使用说明</h2>
|
||||
<p>从创建粒子到保存、导出的快速入门</p>
|
||||
</div>
|
||||
<button class="help-close" aria-label="关闭使用说明" title="关闭 (Esc)" @click="showHelp = false">✕</button>
|
||||
</header>
|
||||
|
||||
<div class="help-body">
|
||||
<section class="help-section help-start">
|
||||
<h3>快速开始</h3>
|
||||
<ol>
|
||||
<li><b>创建对象:</b>在左侧“场景对象”中添加粒子、碰撞、路径或 Spine。</li>
|
||||
<li><b>编辑粒子:</b>选中粒子系统,依次调整发射、形状、属性、外观资源和修改器。</li>
|
||||
<li><b>查看效果:</b>使用底部时间轴播放、暂停或拖动到指定帧;修改参数后会自动重新计算。</li>
|
||||
<li><b>保存成果:</b>配置文件用于继续编辑;“导出 Spine Json”用于导入 Spine 4.2。</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<div class="help-grid">
|
||||
<section class="help-section">
|
||||
<h3>常用功能</h3>
|
||||
<dl class="help-list">
|
||||
<div><dt>小眼睛</dt><dd>单独显示或隐藏场景对象;隐藏的粒子系统不会导出。</dd></div>
|
||||
<div><dt>+Copy</dt><dd>完整复制当前选中对象,方便对比不同参数效果。</dd></div>
|
||||
<div><dt>背景图</dt><dd>上传参考图并调整位置和透明度,只用于画布预览。</dd></div>
|
||||
<div><dt>系统设置</dt><dd>管理撤回步数、发射点、骨骼、蒙皮网格和刻度显示。</dd></div>
|
||||
<div><dt>配置管理</dt><dd>保存当前编辑数据、加载配置,或直接使用预设文件。</dd></div>
|
||||
<div><dt>Spine 骨骼</dt><dd>在层级树搜索并选择骨骼,可用于粒子发射器跟随。</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="help-section">
|
||||
<h3>快捷键</h3>
|
||||
<div class="shortcut-list">
|
||||
<div><kbd>Ctrl/⌘</kbd><span>+</span><kbd>Z</kbd><em>撤回上一步</em></div>
|
||||
<div><kbd>Ctrl/⌘</kbd><span>+</span><kbd>S</kbd><em>保存配置</em></div>
|
||||
<div><kbd>Q</kbd><em>拖动画布</em></div>
|
||||
<div><kbd>W</kbd><em>位移工具</em></div>
|
||||
<div><kbd>E</kbd><em>旋转工具</em></div>
|
||||
<div><kbd>R</kbd><em>缩放工具</em></div>
|
||||
<div><kbd>D</kbd><em>播放 / 暂停</em></div>
|
||||
<div><kbd>S</kbd><em>回到第一帧</em></div>
|
||||
</div>
|
||||
<p class="help-note">Q / W / E / R 需要先点击画布。光标位于输入框时,单键快捷键不会触发。</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="help-section help-export">
|
||||
<h3>配置与导出有什么区别?</h3>
|
||||
<p><b>保存配置</b>会保存编辑器中的全部对象和参数,之后可以继续修改;<b>导出 Spine Json</b>只导出显示中的粒子系统,并把跟随、碰撞和拖尾结果烘焙为独立动画数据。</p>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="hud" v-if="info">
|
||||
系统: <b class="hud-number hud-system-count">{{ info.systems }}</b> · 粒子(骨骼): <b class="hud-number hud-particle-count">{{ info.particles }}</b> · 根骨骼: <b>root</b>
|
||||
<span class="hud-tip">滚轮缩放 · 左键拖动画布</span>
|
||||
@@ -122,6 +189,7 @@ const holder = ref<HTMLElement | null>(null)
|
||||
const info = ref<{ systems: number; particles: number } | null>(null)
|
||||
const showSettings = ref(false)
|
||||
const showBackgroundMenu = ref(false)
|
||||
const showHelp = ref(false)
|
||||
const backgroundFileInput = ref<HTMLInputElement | null>(null)
|
||||
const backgroundFileName = computed(() => store.settings.backgroundImageName)
|
||||
// 根变换 gizmo 工具:'translate' | 'rotate' | 'scale' | ''(无)
|
||||
@@ -137,9 +205,16 @@ if (store.settings.backgroundImageName == null) store.settings.backgroundImageNa
|
||||
if (store.settings.backgroundImageData == null) store.settings.backgroundImageData = ''
|
||||
if (store.settings.showEmitter == null) store.settings.showEmitter = true
|
||||
if (store.settings.showBones == null) store.settings.showBones = false
|
||||
if (!(store.settings.bonePointDrawSize > 0)) store.settings.bonePointDrawSize = 1
|
||||
if (!(store.settings.boneAxisDrawSize > 0)) store.settings.boneAxisDrawSize = 2
|
||||
if (store.settings.showSkinMesh == null) store.settings.showSkinMesh = false
|
||||
if (store.settings.showGrid == null) store.settings.showGrid = true
|
||||
|
||||
function normalizeBoneDebugSizes() {
|
||||
store.settings.bonePointDrawSize = Math.min(10, Math.max(0.1, Number(store.settings.bonePointDrawSize) || 1))
|
||||
store.settings.boneAxisDrawSize = Math.min(10, Math.max(0.1, Number(store.settings.boneAxisDrawSize) || 2))
|
||||
}
|
||||
|
||||
useEditorShortcuts({
|
||||
canvasElement: holder,
|
||||
canvasFocused,
|
||||
@@ -186,8 +261,8 @@ watch(() => [store.activeObjectType, store.activeObjectId], () => selectedPathNo
|
||||
async function loadTexture() {
|
||||
if (dotTex && trailTex) return dotTex
|
||||
const [particleTexture, trailTexture] = await Promise.all([
|
||||
Texture.fromURL('/particles/star.png'),
|
||||
Texture.fromURL('/trails/trail.png'),
|
||||
Texture.fromURL('/images/star.png'),
|
||||
Texture.fromURL('/images/trail.png'),
|
||||
])
|
||||
dotTex = particleTexture
|
||||
trailTex = trailTexture
|
||||
@@ -232,6 +307,16 @@ function toggleBackgroundMenu() {
|
||||
if (showBackgroundMenu.value) showSettings.value = false
|
||||
}
|
||||
|
||||
function openHelp() {
|
||||
showHelp.value = true
|
||||
showSettings.value = false
|
||||
showBackgroundMenu.value = false
|
||||
}
|
||||
|
||||
function onHelpKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && showHelp.value) showHelp.value = false
|
||||
}
|
||||
|
||||
function toggleSettings() {
|
||||
showSettings.value = !showSettings.value
|
||||
if (showSettings.value) showBackgroundMenu.value = false
|
||||
@@ -804,7 +889,7 @@ function syncEmitters() {
|
||||
const cfg = ensureEmitterConfig(sys.config as any)
|
||||
if (trailTex) {
|
||||
for (const resource of cfg.trailResources) {
|
||||
if (!resource.texture && resource.previewUrl === '/trails/trail.png') resource.texture = trailTex
|
||||
if (!resource.texture && (resource.previewUrl === '/images/trail.png' || resource.previewUrl === '/trails/trail.png')) resource.texture = trailTex
|
||||
}
|
||||
}
|
||||
if (dotTex) {
|
||||
@@ -1062,14 +1147,15 @@ function drawGizmo() {
|
||||
}
|
||||
|
||||
// 粒子→骨骼映射:每个活动粒子 = 一根挂在 root 下的骨骼
|
||||
type BonePreview = { boneName: string; x: number; y: number; parentX?: number; parentY?: number; trail?: boolean }
|
||||
type BonePreview = { boneName: string; x: number; y: number; rotation: number; parentX?: number; parentY?: number; trail?: boolean }
|
||||
|
||||
function appendStateBones(target: BonePreview[], state: ParticleState) {
|
||||
target.push({ boneName: state.boneName, x: state.x, y: state.y })
|
||||
// 粒子 rotation 使用数学角度,而 Pixi 画布 Y 轴向下,因此显示轴取反。
|
||||
target.push({ boneName: state.boneName, x: state.x, y: state.y, rotation: -state.rotation })
|
||||
if (!state.trailState?.bones.length) return
|
||||
state.trailState.bones.forEach((bone, index, bones) => {
|
||||
const parent = index > 0 ? bones[index - 1] : { x: state.x, y: state.y }
|
||||
target.push({ boneName: bone.boneName, x: bone.x, y: bone.y, parentX: parent.x, parentY: parent.y, trail: true })
|
||||
state.trailState.bones.forEach((bone) => {
|
||||
// 导出结构中拖尾骨骼全部直接挂在粒子系统骨骼下,画布连线也保持同级语义。
|
||||
target.push({ boneName: bone.boneName, x: bone.x, y: bone.y, rotation: bone.rotation, parentX: 0, parentY: 0, trail: true })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1169,6 +1255,8 @@ function bakeLoopFrames(system: ParticleSystem, emitter: ParticleEmitter, fps: n
|
||||
const finalFrame = cloneParticleFrame(frames[frames.length - 1] || [])
|
||||
if (frames.length > totalFrames) frames.length = totalFrames
|
||||
while (frames.length < totalFrames) frames.push(cloneParticleFrame(finalFrame))
|
||||
// 循环持续拥有自己的衔接帧规则:纯循环和结束段允许第 0 帧保留骨骼;
|
||||
// 启动段则已在上方按定义生成空首帧。
|
||||
system.frames = frames
|
||||
emitter.apply(frames[0] || [])
|
||||
}
|
||||
@@ -1179,15 +1267,36 @@ function drawBones(bones: BonePreview[]) {
|
||||
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 sideLength = 2.5 * axisSize / viewScale
|
||||
const forwardWidth = Math.max(0.65, axisSize * 0.625) / viewScale
|
||||
const sideWidth = Math.max(0.55, axisSize * 0.5) / 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)
|
||||
// 小型局部方向轴:青色为骨骼正方向,黄色为垂直方向。
|
||||
const angle = b.rotation * Math.PI / 180
|
||||
const cos = Math.cos(angle), sin = Math.sin(angle)
|
||||
g.lineStyle(forwardWidth, 0x38d9ff, 0.95)
|
||||
g.moveTo(b.x, b.y)
|
||||
g.lineTo(b.x + cos * forwardLength, b.y + sin * forwardLength)
|
||||
g.beginFill(0x38d9ff, 0.95)
|
||||
g.drawCircle(b.x + cos * forwardLength, b.y + sin * forwardLength, Math.max(0.8, axisSize * 0.625) / viewScale)
|
||||
g.endFill()
|
||||
g.lineStyle(sideWidth, 0xffd166, 0.85)
|
||||
g.moveTo(b.x, b.y)
|
||||
g.lineTo(b.x - sin * sideLength, b.y + cos * sideLength)
|
||||
// 骨骼点
|
||||
g.beginFill(color, 0.9)
|
||||
g.drawCircle(b.x, b.y, 2.5)
|
||||
g.drawCircle(b.x, b.y, pointRadius)
|
||||
g.endFill()
|
||||
}
|
||||
}
|
||||
@@ -1289,14 +1398,28 @@ function drawPaths() {
|
||||
}
|
||||
}
|
||||
|
||||
function followCurveValue(points: Array<{ x: number; y: number }>, t: number) {
|
||||
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) {
|
||||
const amount = (t - a.x) / Math.max(0.0001, b.x - a.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
|
||||
}
|
||||
}
|
||||
@@ -1352,9 +1475,10 @@ function buildPreviewColliders(timeSeconds: number) {
|
||||
const duration = Math.max(0.05, Number(collider.followDuration) || 2)
|
||||
const normalized = Math.min(1, Math.max(0, timeSeconds / duration))
|
||||
const curve = Array.isArray(collider.followCurve) ? collider.followCurve : [{ x: 0, y: 0 }, { x: 1, y: 1 }]
|
||||
const progress = collider.followCurveMode === '线性'
|
||||
? normalized
|
||||
: Math.min(1, Math.max(0, followCurveValue(curve, normalized)))
|
||||
const curveMode = collider.followCurveMode === '跟随导出'
|
||||
? (store.settings.exportSettings.keyframeCurve === 'bezier' ? 'bezier' : 'linear')
|
||||
: collider.followCurveMode === '贝塞尔' ? 'bezier' : 'linear'
|
||||
const progress = Math.min(1, Math.max(0, followCurveValue(curve, normalized, curveMode)))
|
||||
const sample = sampleScenePath(path, progress)
|
||||
return sample
|
||||
? {
|
||||
@@ -1408,6 +1532,7 @@ function loop() {
|
||||
for (const sys of store.systems) {
|
||||
const em = emitterMap.get(sys.id)
|
||||
if (!em) continue
|
||||
em.setFollowExportCurveMode(store.settings.exportSettings.keyframeCurve === 'bezier' ? '贝塞尔' : '线性')
|
||||
const visible = sys.visible !== false
|
||||
em.visible = visible
|
||||
em.renderable = visible
|
||||
@@ -1520,11 +1645,14 @@ function loop() {
|
||||
}
|
||||
for (const [systemId, em] of emitterMap) {
|
||||
const system = store.systems.find((item) => item.id === systemId)
|
||||
const loopFrame = system?.config.mode === 'stream' && system.config.streamBehavior === 'loop'
|
||||
const isLoopStream = system?.config.mode === 'stream' && system.config.streamBehavior === 'loop'
|
||||
const loopFrame = isLoopStream
|
||||
? system.frames?.[tl.frame]
|
||||
: undefined
|
||||
const states = loopFrame || em.update(frameDuration)
|
||||
if (loopFrame) em.apply(loopFrame)
|
||||
const forceEmptyInitialFrame = tl.frame === 0 && !isLoopStream
|
||||
const states = forceEmptyInitialFrame ? [] : (loopFrame || em.update(frameDuration))
|
||||
if (forceEmptyInitialFrame) em.apply([])
|
||||
else if (loopFrame) em.apply(loopFrame)
|
||||
const systemVisible = system?.visible !== false
|
||||
if (systemVisible) for (const state of states) if (state.active) appendStateBones(collected, state)
|
||||
total += states.length
|
||||
@@ -1576,7 +1704,9 @@ function loop() {
|
||||
total++
|
||||
}
|
||||
} else if (emitter) {
|
||||
const states = emitter.update(0)
|
||||
// 尚未录制时,第 0 帧也必须保持绝对空白,不能由 update(0) 提前触发发射。
|
||||
const states = tl.frame === 0 ? [] : emitter.update(0)
|
||||
if (tl.frame === 0) emitter.apply([])
|
||||
if (sys.visible !== false) for (const state of states) if (state.active) appendStateBones(collected, state)
|
||||
total += emitter.activeCount
|
||||
}
|
||||
@@ -1599,6 +1729,7 @@ function loop() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('keydown', onHelpKeydown)
|
||||
const el = holder.value!
|
||||
app = new Application({ width: el.clientWidth || 800, height: el.clientHeight || 600, backgroundAlpha: 0, antialias: true })
|
||||
el.appendChild(app.view as unknown as HTMLCanvasElement)
|
||||
@@ -1611,7 +1742,7 @@ onMounted(async () => {
|
||||
const cfg = ensureEmitterConfig(sys.config as any)
|
||||
if (trailTex) {
|
||||
for (const resource of cfg.trailResources) {
|
||||
if (!resource.texture && resource.previewUrl === '/trails/trail.png') resource.texture = trailTex
|
||||
if (!resource.texture && (resource.previewUrl === '/images/trail.png' || resource.previewUrl === '/trails/trail.png')) resource.texture = trailTex
|
||||
}
|
||||
}
|
||||
for (const resource of cfg.imageResources) {
|
||||
@@ -1652,6 +1783,7 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', onHelpKeydown)
|
||||
cancelAnimationFrame(raf)
|
||||
spineRuntime?.destroy()
|
||||
spineRuntime = null
|
||||
@@ -1723,7 +1855,59 @@ onUnmounted(() => {
|
||||
.background-opacity-row { display: grid; grid-template-columns: 46px 1fr 34px; align-items: center; gap: 7px; margin-top: 10px; font-size: 11px; }
|
||||
.background-opacity-row input { min-width: 0; accent-color: #675de0; }
|
||||
.background-opacity-row b { color: #aeb8cc; font-size: 10px; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.zoom-pct { margin-left: auto; font-variant-numeric: tabular-nums; color: #9aa; min-width: 44px; text-align: center; }
|
||||
.help-btn {
|
||||
height: 28px; margin-left: auto; padding: 0 11px; flex: 0 0 auto; border: 1px solid #3a4963; border-radius: 6px;
|
||||
background: #202a3b; color: #cbd7ed; font-size: 12px; font-weight: 700; cursor: pointer;
|
||||
}
|
||||
.help-btn:hover, .help-btn.on { border-color: #7774ff; background: #343b63; color: #fff; }
|
||||
.zoom-pct { margin-left: 0; font-variant-numeric: tabular-nums; color: #9aa; min-width: 44px; text-align: center; }
|
||||
.help-overlay {
|
||||
position: absolute; inset: 0; z-index: 40; display: flex; align-items: center; justify-content: center; padding: 72px 24px 28px;
|
||||
box-sizing: border-box; background: rgba(5, 8, 15, .72); backdrop-filter: blur(3px);
|
||||
}
|
||||
.help-dialog {
|
||||
width: min(820px, 100%); max-height: 100%; display: flex; flex-direction: column; overflow: hidden;
|
||||
border: 1px solid #3a4864; border-radius: 14px; background: #141a27; color: #b8c3d8;
|
||||
box-shadow: 0 22px 70px rgba(0, 0, 0, .62);
|
||||
}
|
||||
.help-head {
|
||||
display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; padding: 18px 20px 15px;
|
||||
border-bottom: 1px solid #2b354a; background: linear-gradient(135deg, #202942, #181e2d);
|
||||
}
|
||||
.help-head h2 { margin: 0; color: #eef2ff; font-size: 19px; }
|
||||
.help-head p { margin: 5px 0 0; color: #7786a3; font-size: 11px; }
|
||||
.help-close {
|
||||
width: 28px; height: 28px; flex: 0 0 28px; border: 1px solid #43516b; border-radius: 6px;
|
||||
background: #273249; color: #ccd7ed; cursor: pointer;
|
||||
}
|
||||
.help-close:hover { border-color: #7774ff; background: #3b4670; color: #fff; }
|
||||
.help-body { min-height: 0; overflow: auto; padding: 18px 20px 21px; }
|
||||
.help-section { padding: 14px 15px; border: 1px solid #2c3850; border-radius: 10px; background: #181f2e; }
|
||||
.help-section h3 { margin: 0 0 11px; color: #9fb2ff; font-size: 13px; }
|
||||
.help-start ol { margin: 0; padding-left: 21px; color: #aab6cc; font-size: 12px; line-height: 1.75; }
|
||||
.help-start li::marker { color: #7774ff; font-weight: 700; }
|
||||
.help-section b { color: #dce4f7; }
|
||||
.help-grid { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(250px, .85fr); gap: 12px; margin-top: 12px; }
|
||||
.help-list { display: grid; gap: 0; margin: 0; }
|
||||
.help-list > div { display: grid; grid-template-columns: 78px 1fr; gap: 9px; padding: 7px 0; border-bottom: 1px solid #263146; }
|
||||
.help-list > div:last-child { border-bottom: 0; }
|
||||
.help-list dt { color: #d3dcf0; font-size: 11px; font-weight: 700; }
|
||||
.help-list dd { margin: 0; color: #8795ae; font-size: 11px; line-height: 1.5; }
|
||||
.shortcut-list { display: grid; gap: 7px; }
|
||||
.shortcut-list > div { display: flex; align-items: center; min-height: 25px; gap: 5px; }
|
||||
.shortcut-list kbd {
|
||||
min-width: 24px; box-sizing: border-box; padding: 3px 6px; border: 1px solid #465470; border-bottom-width: 2px;
|
||||
border-radius: 5px; background: #101623; color: #e0e7f6; font: 10px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; text-align: center;
|
||||
}
|
||||
.shortcut-list span { color: #65728b; font-size: 10px; }
|
||||
.shortcut-list em { margin-left: auto; color: #9ca9c1; font-size: 11px; font-style: normal; }
|
||||
.help-note { margin: 11px 0 0; padding-top: 9px; border-top: 1px solid #2a3549; color: #697791; font-size: 10px; line-height: 1.55; }
|
||||
.help-export { margin-top: 12px; }
|
||||
.help-export p { margin: 0; color: #8997b0; font-size: 11px; line-height: 1.65; }
|
||||
@media (max-width: 760px) {
|
||||
.help-overlay { padding: 66px 12px 14px; }
|
||||
.help-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
.hud { position: absolute; bottom: 12px; left: 12px; padding: 8px 12px; background: rgba(20,20,32,0.75); border-radius: 8px; font-size: 12px; color: #a0a8cc; }
|
||||
.hud-number { display: inline-block; box-sizing: border-box; text-align: right; font-variant-numeric: tabular-nums; font-feature-settings: 'tnum' 1; }
|
||||
.hud-system-count { width: 3ch; }
|
||||
|
||||
@@ -0,0 +1,712 @@
|
||||
# SpineParticle · Spine JSON 导出设计标准规范
|
||||
|
||||
> 文档性质:导出功能开发、验收与后续维护的唯一对齐基线
|
||||
> 规范状态:已完成产品规则对齐,导出设置面板已实现,JSON 导出逻辑尚未开始
|
||||
> 目标格式:Spine JSON 4.2
|
||||
> 主要用途:将导出 JSON 重新导入 Spine 编辑器继续编辑
|
||||
> 最后更新:2026-08-31
|
||||
|
||||
---
|
||||
|
||||
## 一、规范目的
|
||||
|
||||
本规范用于固定 SpineParticle 的 Spine JSON 导出范围、骨架结构、动画烘焙、资源引用、拖尾蒙皮、关键帧压缩和导出设置语义,避免后续开发因上下文丢失或实现人员变化而偏离已确认规则。
|
||||
|
||||
后续实现若与本文冲突,应先重新确认并更新本文,不能仅在代码中改变既定语义。
|
||||
|
||||
---
|
||||
|
||||
## 二、导出目标与边界
|
||||
|
||||
### 2.1 导出目标
|
||||
|
||||
导出结果是一个能够由 Spine 4.2 识别,并能重新导入 Spine 编辑器继续编辑的 JSON 骨架动画文件。
|
||||
|
||||
导出数据必须满足:
|
||||
|
||||
1. 粒子表现不依赖 SpineParticle 编辑器运行时;
|
||||
2. 粒子、序列帧、颜色、透明度、缩放、旋转、拖尾等表现被转换为 Spine 原生骨骼、插槽、附件与动画时间轴;
|
||||
3. 与其他场景模块发生关联的粒子表现必须烘焙为独立动画数据;
|
||||
4. 文件结构优先兼顾可编辑性、动画精度和合理体积;
|
||||
5. 导出后使用 Spine 4.2 Runtime 进行结构解析验证。
|
||||
|
||||
### 2.2 导出内容
|
||||
|
||||
只导出场景中的粒子系统相关内容:
|
||||
|
||||
- 粒子系统骨骼;
|
||||
- 粒子图片插槽和 Region 附件;
|
||||
- 粒子位移、旋转、缩放动画;
|
||||
- 粒子颜色和透明度动画;
|
||||
- 粒子图片与序列帧附件切换;
|
||||
- 拖尾骨骼链;
|
||||
- 拖尾加权 Mesh、颜色、透明度和资源切换;
|
||||
- 项目中的“所属动画”及其粒子时间轴。
|
||||
|
||||
### 2.3 不导出的内容
|
||||
|
||||
以下场景对象本身不进入 JSON:
|
||||
|
||||
- 碰撞体;
|
||||
- 路径;
|
||||
- 已加载的 Spine 对象;
|
||||
- 原 Spine 对象中的骨骼、插槽、皮肤、图片和动画;
|
||||
- 编辑器辅助线、网格、背景图、骨骼预览点和调试显示。
|
||||
|
||||
这些模块若影响了粒子运动,必须只烘焙其最终结果,不能在导出 JSON 中保留对原对象的引用。
|
||||
|
||||
### 2.4 场景显隐规则
|
||||
|
||||
- 场景对象小眼睛关闭的粒子系统不导出;
|
||||
- 显示状态正常的粒子系统参与导出;
|
||||
- 碰撞、路径和 Spine 对象无论是否显示都不作为对象导出;
|
||||
- 若可见粒子系统的模拟依赖有效的碰撞、路径或 Spine 跟随,其最终影响仍应被烘焙。
|
||||
|
||||
---
|
||||
|
||||
## 三、格式依据与版本规则
|
||||
|
||||
### 3.1 格式依据
|
||||
|
||||
实现时依次以以下内容作为格式依据:
|
||||
|
||||
1. Spine 官方 GitHub 仓库中的 Spine 4.2 JSON 示例;
|
||||
2. 项目当前使用的 `@esotericsoftware/spine-core 4.2.119` JSON 解析器;
|
||||
3. 项目已有的 Spine 4.2 官方导出样本;
|
||||
4. Spine 官方 JSON 格式说明。
|
||||
|
||||
官方中文说明中的部分示例仍使用 Spine 3.8,不能直接照搬旧版字段。实现必须重点核对 Spine 4.2 的:
|
||||
|
||||
- `skins` 结构;
|
||||
- `rgba` 插槽时间轴;
|
||||
- `attachments` 动画结构;
|
||||
- 加权 Mesh 顶点编码;
|
||||
- 附件切换;
|
||||
- 多动画结构;
|
||||
- Draw Order 字段命名;
|
||||
- Sequence 附件及时间轴结构。
|
||||
|
||||
### 3.2 版本范围
|
||||
|
||||
第一版只支持:
|
||||
|
||||
```text
|
||||
Spine 4.2
|
||||
```
|
||||
|
||||
设置面板保留下拉框,以便以后扩展版本,但第一版不得提供没有真实兼容实现的 4.0、4.1 或其他版本选项。
|
||||
|
||||
---
|
||||
|
||||
## 四、整体 JSON 结构
|
||||
|
||||
导出 JSON 的主要结构为:
|
||||
|
||||
```text
|
||||
skeleton
|
||||
bones
|
||||
slots
|
||||
skins
|
||||
animations
|
||||
```
|
||||
|
||||
不输出无关约束、碰撞体或路径附件。
|
||||
|
||||
推荐骨架层级:
|
||||
|
||||
```text
|
||||
root
|
||||
├─ ParticleSystem1
|
||||
│ ├─ _particle_000
|
||||
│ ├─ _particle_001
|
||||
│ ├─ _particle_000_trail_1_0
|
||||
│ │ └─ _particle_000_trail_1_1
|
||||
│ │ └─ ...
|
||||
│ └─ ...
|
||||
└─ ...
|
||||
```
|
||||
|
||||
每个粒子系统必须作为 `root` 下的独立父骨骼,粒子骨骼统一使用简洁的 `_particle_000` 样式并归入对应系统。所有骨骼、插槽和附件名称仍须全局唯一;多系统发生重名时只为后续同名骨骼追加最短编号后缀。
|
||||
|
||||
---
|
||||
|
||||
## 五、Setup Pose 标准
|
||||
|
||||
### 5.1 普通骨骼
|
||||
|
||||
除拖尾子骨骼链外,所有导出骨骼的 Setup Pose 使用统一默认值:
|
||||
|
||||
```text
|
||||
x = 0
|
||||
y = 0
|
||||
rotation = 0
|
||||
scaleX = 1
|
||||
scaleY = 1
|
||||
```
|
||||
|
||||
粒子系统在编辑器中的位置、旋转和缩放不写入普通骨骼 Setup Pose,必须进入对应动画时间轴。
|
||||
|
||||
### 5.2 拖尾骨骼链例外
|
||||
|
||||
拖尾是加权 Mesh 的绑定骨骼链。为了保证导入 Spine 后初始蒙皮结构正确:
|
||||
|
||||
- 拖尾链保持正常父子层级;
|
||||
- 子骨骼保留合理的绑定位置和骨骼长度;
|
||||
- 子骨骼不能全部重叠在原点;
|
||||
- 动画帧中的拖尾世界状态转换为相对父骨骼的局部位移和局部旋转;
|
||||
- 拖尾链根独立于粒子图片骨骼的旋转和缩放,避免粒子图片变换意外污染拖尾蒙皮。
|
||||
|
||||
### 5.3 Setup Pose 可见性
|
||||
|
||||
所有蒙皮绑定完成后的粒子 Region 和拖尾 Mesh 在 Setup Pose 中默认透明度为 0,避免导入 Spine 后干扰已有图像显示。
|
||||
|
||||
动画开始后,由附件切换和 `rgba` 时间轴恢复正确图片、颜色与透明度。
|
||||
|
||||
---
|
||||
|
||||
## 六、粒子到 Spine 数据的映射
|
||||
|
||||
| SpineParticle 数据 | Spine 4.2 数据 |
|
||||
|---|---|
|
||||
| 粒子系统 | 系统根骨骼 |
|
||||
| 粒子池槽位或独立出生粒子 | 粒子骨骼 |
|
||||
| 粒子图片 | Region 附件 |
|
||||
| 粒子图片显示层 | Slot |
|
||||
| 粒子位置 | Bone Translate Timeline |
|
||||
| 粒子旋转 | Bone Rotate Timeline |
|
||||
| 粒子缩放 | Bone Scale Timeline |
|
||||
| 粒子颜色与透明度 | Slot RGBA Timeline |
|
||||
| 粒子出生、死亡、假死 | Attachment 与 RGBA Timeline |
|
||||
| 多图片资源 | 同一 Slot 下的多个 Region 附件 |
|
||||
| 序列帧 | Attachment Timeline 切换 Region |
|
||||
| 混合模式 | Slot Blend |
|
||||
| 拖尾 | 加权 Mesh + 拖尾骨骼链 |
|
||||
| 拖尾颜色与透明度 | 拖尾 Slot RGBA Timeline |
|
||||
| 所属动画 | `animations` 下的动画名称 |
|
||||
|
||||
---
|
||||
|
||||
## 七、粒子骨骼与对象池
|
||||
|
||||
### 7.1 开启骨骼对象池
|
||||
|
||||
开启时:
|
||||
|
||||
- 按粒子系统最大同时存活数量建立固定粒子骨骼池;
|
||||
- 粒子死亡后骨骼可供后续粒子复用;
|
||||
- 图片 Slot 和对应拖尾骨骼链随粒子骨骼一起复用;
|
||||
- 文件骨骼数量和时间轴体积应显著小于不复用模式;
|
||||
- 复用前后通过 Attachment 和 RGBA 关键帧明确隐藏、显示及切换资源。
|
||||
|
||||
### 7.2 关闭骨骼对象池
|
||||
|
||||
关闭时:
|
||||
|
||||
- 每一次逻辑粒子出生建立独立骨骼;
|
||||
- 已死亡粒子的骨骼不再分配给后续粒子;
|
||||
- 动画较长或发射率较高时允许产生大量骨骼;
|
||||
- 界面无需阻止,但导出前可以提示预计骨骼数量和文件体积。
|
||||
|
||||
### 7.3 循环动画例外
|
||||
|
||||
纯循环持续动画必须保证循环衔接帧两端的粒子身份稳定。即使关闭对象池,也必须为跨越循环接缝的粒子维持稳定骨骼映射,避免循环首尾出现身份跳变。
|
||||
|
||||
---
|
||||
|
||||
## 八、图片与序列帧
|
||||
|
||||
### 8.1 图片文件
|
||||
|
||||
导出功能不复制、不下载、不嵌入 PNG,也不生成 Atlas。
|
||||
|
||||
JSON 只负责输出正确的图片资源路径。用户将在 Spine 中重新扫描并指定图片文件夹。
|
||||
|
||||
禁止把以下编辑器内部地址写入附件路径:
|
||||
|
||||
- Data URL;
|
||||
- Blob URL;
|
||||
- 浏览器临时对象地址;
|
||||
- 本机绝对文件路径。
|
||||
|
||||
### 8.2 图片路径
|
||||
|
||||
设置中的图片路径,例如:
|
||||
|
||||
```text
|
||||
./images/
|
||||
```
|
||||
|
||||
写入 `skeleton.images`。
|
||||
|
||||
附件 `path` 只写相对于图片文件夹的资源路径,不重复附加 `./images/`。附件路径应:
|
||||
|
||||
- 保留资源子文件夹结构;
|
||||
- 统一使用 `/`;
|
||||
- 清理重复的 `./`;
|
||||
- 使用稳定文件名;
|
||||
- 默认使用不含图片扩展名的 Spine 资源路径;
|
||||
- 检测并提示重复路径。
|
||||
|
||||
### 8.3 图片缺失
|
||||
|
||||
图片缺失不阻止导出。
|
||||
|
||||
- 仍然输出对应 Region 或 Mesh 附件;
|
||||
- 保留用户可识别的文件名和路径;
|
||||
- Spine 中允许显示缺失图片,用户可根据文件名补充文件;
|
||||
- 能取得图片原始尺寸时写入真实尺寸;
|
||||
- 完全无法取得尺寸时写入安全占位尺寸并给出提示;
|
||||
- 缺图、路径重复或占位尺寸属于警告,不直接取消 JSON 下载。
|
||||
|
||||
### 8.4 图片尺寸、缩放和锚点
|
||||
|
||||
- Region `width/height` 使用图片原始像素尺寸;
|
||||
- 粒子资源缩放已经体现在粒子骨骼 Scale 动画中,不得重复乘到图片尺寸;
|
||||
- Pixi 锚点转换为 Region 相对骨骼中心的偏移;
|
||||
- 转换时必须处理 Spine 数学坐标 Y 向上与 Pixi 屏幕坐标 Y 向下的差异;
|
||||
- 实现后必须使用非中心锚点和旋转状态进行视觉对照。
|
||||
|
||||
### 8.5 序列帧
|
||||
|
||||
一组序列帧始终只使用一根粒子骨骼,不因图片数量增加骨骼。
|
||||
|
||||
第一版不依赖 Spine 原生 Sequence 命名约束,而采用:
|
||||
|
||||
- 每张序列图片建立独立 Region 附件;
|
||||
- 使用 Attachment Timeline 切换当前 Region;
|
||||
- 按烘焙后的最终图片索引输出,不在导出端重新推演播放模式。
|
||||
|
||||
需要完整支持:
|
||||
|
||||
- 单次-尾帧消失;
|
||||
- 单次-尾帧固定;
|
||||
- 正向循环;
|
||||
- 反向循环。
|
||||
|
||||
---
|
||||
|
||||
## 九、拖尾加权 Mesh 标准
|
||||
|
||||
### 9.1 禁止项
|
||||
|
||||
拖尾不得使用逐帧 `attachments → deform` 时间轴作为正式导出方案。
|
||||
|
||||
### 9.2 正式结构
|
||||
|
||||
拖尾直接转换为:
|
||||
|
||||
```text
|
||||
拖尾骨骼链 + 加权 Mesh + 骨骼动画
|
||||
```
|
||||
|
||||
复用项目现有拖尾数据:
|
||||
|
||||
- `trailGridRows / trailGridCols` 生成网格拓扑;
|
||||
- 当前三角形索引写入 `triangles`;
|
||||
- 当前 UV 与图片旋转写入 `uvs`;
|
||||
- `trailSkinWeights` 中的 `boneA / boneB / weight` 转换为 Spine 加权 Mesh 顶点编码;
|
||||
- `trailState.bones` 转换为拖尾骨骼链动画;
|
||||
- 拖尾资源切换使用同一拖尾 Slot 下的多个 Mesh 附件和 Attachment Timeline;
|
||||
- 拖尾颜色和透明度使用拖尾 Slot 的 RGBA Timeline。
|
||||
|
||||
### 9.3 拖尾动画映射
|
||||
|
||||
- 拖尾长度由骨骼链各节点位置体现;
|
||||
- 拖尾方向由骨骼旋转体现;
|
||||
- 拖尾宽度由骨骼垂直缩放体现;
|
||||
- 拖尾形状曲线进入 Mesh 的绑定顶点分布;
|
||||
- 拖尾图片旋转进入 UV;
|
||||
- 拖尾头部必须与粒子最终渲染位置一致;
|
||||
- 每帧世界状态转换为父子骨骼局部状态后再写入时间轴。
|
||||
|
||||
### 9.4 权重标准
|
||||
|
||||
- 每个顶点使用项目已有的相邻骨骼影响关系;
|
||||
- 每个顶点权重和必须在允许误差内等于 1;
|
||||
- 骨骼索引必须对应最终 `bones` 数组顺序;
|
||||
- 不允许引用不存在或被排除的骨骼;
|
||||
- 导出后需要检查弯曲、宽度变化、资源旋转和粒子旋转情况下的拖尾形态。
|
||||
|
||||
---
|
||||
|
||||
## 十、外部关联烘焙
|
||||
|
||||
### 10.1 通用规则
|
||||
|
||||
所有导出动画先执行一次独立、确定性的离线烘焙:
|
||||
|
||||
1. 使用当前粒子系统随机种子重置模拟;
|
||||
2. 按导出 FPS 逐帧推进;
|
||||
3. 计算路径跟随、Spine 骨骼跟随、碰撞和全部修改器;
|
||||
4. 记录粒子和拖尾最终渲染状态;
|
||||
5. 将外部对象影响转换为系统根或粒子骨骼动画;
|
||||
6. JSON 中不保留任何外部对象引用。
|
||||
|
||||
### 10.2 碰撞
|
||||
|
||||
- 不导出碰撞体;
|
||||
- 碰撞造成的位置、速度方向、暂停、销毁和反弹结果进入最终粒子动画;
|
||||
- 碰撞突变前后必须保留关键帧,不能跨越突变进行平滑压缩。
|
||||
|
||||
### 10.3 路径跟随
|
||||
|
||||
- 不导出路径对象或 Path Constraint;
|
||||
- 本地空间跟随烘焙为系统根运动与粒子局部运动;
|
||||
- 世界空间跟随烘焙为粒子最终世界运动,已出生粒子不能错误继承后续路径移动;
|
||||
- 路径方向和旋转表现按画布最终结果写入。
|
||||
|
||||
### 10.4 Spine 骨骼跟随
|
||||
|
||||
- 不导出原 Spine 对象或原目标骨骼;
|
||||
- 跟随位置和用户设置的偏移进入最终动画;
|
||||
- 不额外继承原 Spine 骨骼的缩放和旋转,保持编辑器现有语义;
|
||||
- 目标失效时按编辑器当前默认回退逻辑处理。
|
||||
|
||||
### 10.5 坐标空间
|
||||
|
||||
不能只读取粒子局部快照直接输出。每帧必须组合:
|
||||
|
||||
```text
|
||||
粒子局部状态
|
||||
+ 系统根变换
|
||||
+ 当前帧跟随目标产生的有效变换
|
||||
= 最终可导出状态
|
||||
```
|
||||
|
||||
同时统一处理 Pixi Y 向下与 Spine Y 向上的坐标转换、旋转方向和父子局部变换。
|
||||
|
||||
---
|
||||
|
||||
## 十一、动画归属与时长
|
||||
|
||||
### 11.1 多动画
|
||||
|
||||
一个 JSON 包含项目中的多个“所属动画”:
|
||||
|
||||
```json
|
||||
"animations": {
|
||||
"animation": {},
|
||||
"animation2": {},
|
||||
"start": {},
|
||||
"loop": {},
|
||||
"end": {}
|
||||
}
|
||||
```
|
||||
|
||||
- 每个粒子系统只写入它所属的动画;
|
||||
- 多个粒子系统可以属于同一动画;
|
||||
- 同一动画时长取参与导出的粒子系统中最长者;
|
||||
- 较短系统到达末帧后保持符合自身模式的末帧状态;
|
||||
- Spine、碰撞体和路径自身时长不参与导出动画时长计算。
|
||||
|
||||
### 11.2 空动画
|
||||
|
||||
空动画是否保留遵循“排除无内容动画”设置:
|
||||
|
||||
- 设置关闭:所属动画列表中的空动画仍输出为 `{}`;
|
||||
- 设置开启:没有任何可见粒子动画内容的动画不写入 JSON。
|
||||
|
||||
### 11.3 循环持续三段动画
|
||||
|
||||
- 启动段、纯循环段和结束段按用户当前保存的状态导出;
|
||||
- 循环衔接帧的粒子身份和视觉状态必须稳定;
|
||||
- 纯循环段保留假死粒子的骨骼身份,并通过附件隐藏或透明度 0 表现死亡;
|
||||
- 循环首尾接缝不得因关键帧精简产生卡顿或突变;
|
||||
- 延迟时间保持现有循环持续语义;
|
||||
- 所属动画命名继续沿用编辑器已有逻辑,不由导出器自动改名。
|
||||
|
||||
### 11.4 时间端点
|
||||
|
||||
- 时间为 `frameIndex / exportFps`;
|
||||
- 时间轴包含可选择的最后端点帧;
|
||||
- 循环动画允许保留首尾相同的末端点;
|
||||
- 时长不足完整导出帧时按规则向上取整,不能截断最长寿命粒子或拖尾。
|
||||
|
||||
---
|
||||
|
||||
## 十二、关键帧烘焙与压缩
|
||||
|
||||
### 12.1 总体策略
|
||||
|
||||
采用:
|
||||
|
||||
```text
|
||||
完整逐帧烘焙 → 分类 → 安全精简 → 误差验证
|
||||
```
|
||||
|
||||
不能先减少采样再模拟,也不能简单每隔固定帧保留一帧。
|
||||
|
||||
### 12.2 离散数据
|
||||
|
||||
以下数据不能进行数值插值,只保留变化点并使用阶跃语义:
|
||||
|
||||
- 粒子出生和死亡;
|
||||
- 图片显示和隐藏;
|
||||
- 粒子图片资源切换;
|
||||
- 序列帧附件切换;
|
||||
- 拖尾资源切换;
|
||||
- 混合模式变化;
|
||||
- 假死状态切换。
|
||||
|
||||
连续多帧相同附件或相同状态只输出第一次变化关键帧。
|
||||
|
||||
### 12.3 连续数据
|
||||
|
||||
以下数据先逐帧烘焙,再进行误差受控精简:
|
||||
|
||||
- 粒子位置;
|
||||
- 粒子旋转;
|
||||
- 粒子缩放;
|
||||
- 粒子颜色和透明度;
|
||||
- 拖尾骨骼位置;
|
||||
- 拖尾骨骼旋转;
|
||||
- 拖尾宽度缩放。
|
||||
|
||||
精简器检查删除中间关键帧后,Spine 插值结果是否仍在误差范围内。采用以下视觉容差:
|
||||
|
||||
| 数据 | 建议容差 |
|
||||
|---|---:|
|
||||
| 粒子位置 | 0.05–0.1 像素(当前实现取 0.1) |
|
||||
| 拖尾骨骼位置 | 0.05–0.1 像素(当前实现取 0.1) |
|
||||
| 旋转 | 0.01° |
|
||||
| 缩放 | 0.01 |
|
||||
| 透明度 | 0.01 |
|
||||
| RGB | 单通道 1/255 |
|
||||
|
||||
出生、死亡、附件切换、碰撞突变和循环接缝仍强制保留,不允许跨边界压缩。
|
||||
|
||||
### 12.4 强制保留点
|
||||
|
||||
以下位置不得跨越压缩:
|
||||
|
||||
- 粒子出生帧;
|
||||
- 粒子死亡帧;
|
||||
- 附件显示、隐藏和切换帧;
|
||||
- 碰撞、反弹、暂停和销毁前后;
|
||||
- 运动方向突然变化处;
|
||||
- 拖尾出现和消失处;
|
||||
- 循环首尾衔接区域;
|
||||
- 延迟段和正式运动段边界;
|
||||
- 旋转角度展开产生的边界。
|
||||
|
||||
### 12.5 旋转处理
|
||||
|
||||
压缩前先进行角度展开。例如:
|
||||
|
||||
```text
|
||||
358° → 1° → 4°
|
||||
```
|
||||
|
||||
应转换为:
|
||||
|
||||
```text
|
||||
358° → 361° → 364°
|
||||
```
|
||||
|
||||
避免 Spine 插值时反向旋转一整圈。
|
||||
|
||||
### 12.6 插值曲线设置
|
||||
|
||||
- `线性 (Linear)`:连续数据使用线性插值和线性误差精简;
|
||||
- `平滑贝塞尔 (Smooth Bézier)`:对连续段拟合贝塞尔,并逐帧检查误差;
|
||||
- 贝塞尔拟合不满足容差时必须增加关键帧,不能牺牲精度;
|
||||
- 离散数据、突变点、碰撞边界和循环接缝不受平滑贝塞尔设置影响。
|
||||
|
||||
---
|
||||
|
||||
## 十三、导出设置标准
|
||||
|
||||
导出设置是全局编辑器设置,不属于单个粒子系统。必须参与:
|
||||
|
||||
- 保存配置;
|
||||
- 加载配置;
|
||||
- 预设加载;
|
||||
- 撤回;
|
||||
- 旧配置默认值补齐。
|
||||
|
||||
### 13.1 设置项与默认值
|
||||
|
||||
| 设置 | 默认值 | 规则 |
|
||||
|---|---|---|
|
||||
| 图片路径 | `./images/` | 写入 `skeleton.images` |
|
||||
| Spine 版本 | `Spine 4.2` | 第一版唯一选项 |
|
||||
| 关键帧插值曲线 | `线性 (Linear)` | 可切换平滑贝塞尔 |
|
||||
| 导出 FPS | `30` | 支持 1~240 的正整数 |
|
||||
| 不写 fps | 关闭 | 只省略 `skeleton.fps` 元数据 |
|
||||
| 整数帧对齐 | 开启 | 关键帧位于导出 FPS 的整数帧网格 |
|
||||
| 骨骼对象池 | 开启 | 复用粒子骨骼和拖尾链 |
|
||||
| 排除无内容动画 | 关闭 | 关闭时保留空动画 `{}` |
|
||||
| 默认文件名 | `SpineParticle.json` | 浏览器处理重复下载名称 |
|
||||
|
||||
### 13.2 导出 FPS 语义
|
||||
|
||||
修改导出 FPS 时保持动画实际秒数不变。
|
||||
|
||||
例如:
|
||||
|
||||
```text
|
||||
30f / 30 FPS = 1 秒
|
||||
60f / 60 FPS = 1 秒
|
||||
```
|
||||
|
||||
不能在导出 60 FPS 时仍只输出 30 帧并把动画缩短为 0.5 秒。
|
||||
|
||||
“不写 fps”仅省略 Spine JSON 中的 FPS 元数据,内部烘焙、采样和时间换算仍使用导出 FPS。
|
||||
|
||||
### 13.3 整数帧对齐
|
||||
|
||||
开启时:
|
||||
|
||||
```text
|
||||
time = integerFrame / exportFps
|
||||
```
|
||||
|
||||
不能产生小数帧位置。无法整除的尾端时长向上取整,同时保持需要完成的粒子生命周期和拖尾状态。
|
||||
|
||||
---
|
||||
|
||||
## 十四、命名与文件规则
|
||||
|
||||
### 14.1 默认文件名
|
||||
|
||||
```text
|
||||
SpineParticle.json
|
||||
```
|
||||
|
||||
### 14.2 名称安全
|
||||
|
||||
导出器必须:
|
||||
|
||||
- 为系统、粒子、拖尾、Slot 和 Attachment 生成稳定名称;
|
||||
- 处理空名称、重复名称和非法路径分隔符;
|
||||
- 保留用户所属动画名称;
|
||||
- 不在没有必要时修改用户可见的图片文件名;
|
||||
- 保证同一配置、同一种子和同一导出设置产生稳定命名。
|
||||
|
||||
---
|
||||
|
||||
## 十五、代码结构标准
|
||||
|
||||
导出功能必须使用独立文件,不能把大量逻辑继续堆入 `ParticlePanel.vue`、`Stage.vue` 或 Store。
|
||||
|
||||
推荐结构:
|
||||
|
||||
```text
|
||||
src/export/
|
||||
├─ spineExportSettings.ts
|
||||
├─ spineJsonTypes.ts
|
||||
├─ particleExportBaker.ts
|
||||
├─ spineMeshBuilder.ts
|
||||
├─ spineKeyframeReducer.ts
|
||||
├─ spineJsonExporter.ts
|
||||
└─ spineJsonValidator.ts
|
||||
```
|
||||
|
||||
职责:
|
||||
|
||||
- `spineExportSettings.ts`:导出设置类型、默认值和旧配置补齐;
|
||||
- `spineJsonTypes.ts`:Spine 4.2 JSON 类型;
|
||||
- `particleExportBaker.ts`:确定性逐帧模拟和外部关联解除;
|
||||
- `spineMeshBuilder.ts`:拖尾骨骼链、权重、UV 和三角形生成;
|
||||
- `spineKeyframeReducer.ts`:离散合并和连续时间轴误差精简;
|
||||
- `spineJsonExporter.ts`:组装 Skeleton、Bones、Slots、Skins 和 Animations;
|
||||
- `spineJsonValidator.ts`:导出前后结构验证和警告。
|
||||
|
||||
界面中的“导出Spine Json”按钮只负责调用导出入口、显示结果和下载文件。
|
||||
|
||||
---
|
||||
|
||||
## 十六、验证与验收标准
|
||||
|
||||
### 16.1 结构验证
|
||||
|
||||
导出前检查:
|
||||
|
||||
- 骨骼名称唯一;
|
||||
- 父骨骼先于子骨骼;
|
||||
- Slot 引用的骨骼存在;
|
||||
- Skin 引用的 Slot 存在;
|
||||
- Attachment Timeline 引用的附件存在;
|
||||
- Mesh 骨骼索引有效;
|
||||
- Mesh 权重和正确;
|
||||
- UV、顶点和三角形索引数量匹配;
|
||||
- 动画时间递增;
|
||||
- 颜色为有效 RGBA;
|
||||
- 所有数字有限,不出现 `NaN` 或 `Infinity`。
|
||||
|
||||
### 16.2 Runtime 验证
|
||||
|
||||
生成 JSON 后,使用项目内 Spine 4.2 Runtime 进行一次解析验证。解析失败时不应下载伪成功文件,应显示明确错误位置。
|
||||
|
||||
图片缺失不属于 JSON 结构失败,只显示警告。
|
||||
|
||||
### 16.3 视觉验证
|
||||
|
||||
至少对以下帧进行编辑器画布与导出结果对照:
|
||||
|
||||
- 第一帧;
|
||||
- 粒子第一次出生帧;
|
||||
- 最大粒子数量附近;
|
||||
- 图片或序列帧切换帧;
|
||||
- 碰撞发生前后;
|
||||
- 路径或 Spine 跟随运动中间帧;
|
||||
- 拖尾明显弯曲帧;
|
||||
- 最后一帧;
|
||||
- 纯循环首帧和末帧。
|
||||
|
||||
对照内容包括:
|
||||
|
||||
- 粒子数量;
|
||||
- 粒子位置;
|
||||
- 旋转与缩放;
|
||||
- 图片资源;
|
||||
- 颜色与透明度;
|
||||
- 绘制顺序;
|
||||
- 拖尾位置、方向、宽度和弯曲;
|
||||
- 循环接缝。
|
||||
|
||||
### 16.4 文件体积验收
|
||||
|
||||
- 优先通过对象池、重复状态合并和误差受控关键帧精简减小文件;
|
||||
- 拖尾必须使用加权 Mesh,不能用逐帧 Deform 换取实现简单;
|
||||
- 不能为了缩小文件破坏碰撞、噪声、序列帧或循环精度;
|
||||
- 对象池关闭时允许文件显著增大,但应给出预计规模提示。
|
||||
|
||||
---
|
||||
|
||||
## 十七、当前实施状态
|
||||
|
||||
截至本文落盘:
|
||||
|
||||
1. 导出范围、格式目标和关键规则已对齐;
|
||||
2. 导出设置面板已实现;
|
||||
3. 导出设置已接入保存、加载、预设、撤回和旧配置补齐;
|
||||
4. 默认文件名已定义为 `SpineParticle.json`;
|
||||
5. “导出Spine Json”按钮尚未接入 JSON 生成;
|
||||
6. 正式导出代码必须等待用户明确的“开始”指令后实施;
|
||||
7. 开始实现前应下载并核对 Spine 官方 GitHub 仓库中的 Spine 4.2 加权 Mesh 和多动画 JSON 示例。
|
||||
|
||||
---
|
||||
|
||||
## 十八、禁止偏离项摘要
|
||||
|
||||
以下规则未经重新确认不得改变:
|
||||
|
||||
1. 只导出可见粒子系统;
|
||||
2. 不导出碰撞体、路径和原 Spine 对象;
|
||||
3. 外部关联必须烘焙为独立动画;
|
||||
4. 第一版只支持 Spine 4.2;
|
||||
5. 图片文件不导出,只写正确路径;
|
||||
6. 缺图不阻止导出;
|
||||
7. 序列帧只使用一根粒子骨骼;
|
||||
8. 拖尾必须使用加权 Mesh 和拖尾骨骼链;
|
||||
9. 拖尾不得正式导出为逐帧 Deform;
|
||||
10. 先逐帧烘焙,再安全精简关键帧;
|
||||
11. Setup Pose 普通骨骼位移和旋转为 0、缩放为 1;
|
||||
12. 拖尾子骨骼保留正确绑定链位置;
|
||||
13. Setup Pose 图片和 Mesh 默认透明度为 0;
|
||||
14. 修改 FPS 时保持实际秒数不变;
|
||||
15. 空动画是否保留遵循“排除无内容动画”;
|
||||
16. 默认文件名为 `SpineParticle.json`;
|
||||
17. 导出功能必须拆分为独立代码模块;
|
||||
18. 导出主要用途是重新导入 Spine 编辑器继续编辑。
|
||||
@@ -0,0 +1,771 @@
|
||||
# 网页粒子系统 · 阶段十一
|
||||
|
||||
> 阶段定位:**Spine JSON 导出体系、拖尾蒙皮定版与曲线编辑能力完善**
|
||||
> 完成日期:2026-08-31
|
||||
> 工程目录:`/Users/tianmokeji/Desktop/SpineParticle`
|
||||
> 技术栈:Vue 3 + TypeScript + Pinia + PixiJS 7 + Spine Runtime 4.2 + Vite
|
||||
|
||||
---
|
||||
|
||||
## 一、阶段结论
|
||||
|
||||
阶段十一在阶段十的序列帧和循环持续动画体系上,完成了第一版可重新导入 Spine 编辑器继续编辑的 Spine 4.2 JSON 导出功能,并围绕实际导入结果持续修正了骨骼层级、关键帧数量、加权 Mesh 拓扑、拖尾生命周期和首帧语义。
|
||||
|
||||
本阶段没有把粒子效果导出为不可编辑的逐帧图片,而是把粒子运行结果烘焙为 Spine 可编辑结构:粒子使用独立骨骼和插槽,拖尾使用同级骨骼组与加权 Mesh,运动、旋转、缩放和 RGBA 进入 Spine 时间轴。碰撞、路径和 Spine 预览对象不直接导出,与粒子有关的最终表现均烘焙到粒子动画数据中。
|
||||
|
||||
本阶段主要完成:
|
||||
|
||||
1. 实现 Spine 4.2 JSON 导出设置与文件下载;
|
||||
2. 建立独立导出模块、关键帧精简模块、Mesh 构建模块和结构校验模块;
|
||||
3. 粒子系统导出为系统骨骼、粒子骨骼、插槽和附件;
|
||||
4. 拖尾导出为固定拓扑加权 Mesh 与同级拖尾骨骼组;
|
||||
5. 图片只写资源路径,不把图片文件写入 JSON;
|
||||
6. 隐藏粒子系统不导出,图片缺失时仍保留附件路径;
|
||||
7. 支持固定图和序列帧附件时间轴;
|
||||
8. 支持线性与平滑贝塞尔关键帧精简;
|
||||
9. 按数据类型设置导出误差容限;
|
||||
10. 修正 Spine 版本字段为 `4.2`;
|
||||
11. 修正拖尾 Mesh 的 hull、edges、三角形和权重结构;
|
||||
12. 粒子骨骼增加粒子系统父级,拖尾骨骼改为同级结构;
|
||||
13. 拖尾整组骨骼同生同死,假死改为插槽透明度控制;
|
||||
14. 拖尾未展开阶段同时插值位置和旋转;
|
||||
15. 曲线编辑器实现真正的贝塞尔控制手柄;
|
||||
16. “跟随导出”模式正确读取全局插值设置,不再反向覆盖设置;
|
||||
17. 普通发射模式第 0 帧固定为空,循环持续保留独立衔接规则;
|
||||
18. 修复 `update(0)` 导致爆发数量重复的问题;
|
||||
19. 画布骨骼调试增加固定屏幕尺寸的方向轴;
|
||||
20. 拖尾资源增加拖尾比例以及固定/随机长度、宽度;
|
||||
21. 增加新手使用说明弹窗;
|
||||
22. 完成 TypeScript、Vite、浏览器交互和 Spine Runtime 解析验证。
|
||||
|
||||
详细且长期有效的导出约束另见:
|
||||
|
||||
```text
|
||||
导出设计标准规范.md
|
||||
```
|
||||
|
||||
阶段十一文档记录实际开发结果;导出设计标准规范记录后续导出功能必须继续遵守的标准。
|
||||
|
||||
---
|
||||
|
||||
## 二、Spine JSON 导出范围
|
||||
|
||||
### 2.1 导出对象
|
||||
|
||||
当前只导出可见粒子系统:
|
||||
|
||||
```text
|
||||
visible !== false
|
||||
```
|
||||
|
||||
不会直接导出:
|
||||
|
||||
- 碰撞对象;
|
||||
- 场景路径;
|
||||
- 加载进编辑器用于预览的 Spine 对象;
|
||||
- 背景图;
|
||||
- 画布网格、发射点和调试骨骼;
|
||||
- 编辑器 UI 状态。
|
||||
|
||||
如果粒子运动受到碰撞、路径或其他模块影响,导出的是已经计算完成的粒子最终位置、旋转、缩放和颜色,不保留对外部模块的运行时引用。
|
||||
|
||||
### 2.2 图片资源
|
||||
|
||||
JSON 不内嵌 PNG,也不额外复制图片。附件只写入正确的图片资源路径。导入 Spine 后,由用户重新选择图片文件夹完成扫描。
|
||||
|
||||
图片缺失不会阻止导出。导出器会:
|
||||
|
||||
1. 保留原图片文件名和路径;
|
||||
2. 使用可用尺寸或安全占位尺寸建立附件;
|
||||
3. 在导出结果中返回资源提示;
|
||||
4. 允许用户在 Spine 中按文件名补齐图片。
|
||||
|
||||
默认图片路径:
|
||||
|
||||
```text
|
||||
./images/
|
||||
```
|
||||
|
||||
### 2.3 文件名和版本
|
||||
|
||||
默认导出文件名:
|
||||
|
||||
```text
|
||||
SpineParticle.json
|
||||
```
|
||||
|
||||
骨架版本字段固定为:
|
||||
|
||||
```json
|
||||
{
|
||||
"skeleton": {
|
||||
"spine": "4.2"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
第一版设置面板只提供 Spine 4.2,不伪装支持其他版本。
|
||||
|
||||
---
|
||||
|
||||
## 三、导出设置
|
||||
|
||||
### 3.1 当前参数
|
||||
|
||||
导出设置模块包含:
|
||||
|
||||
| 参数 | 当前规则 |
|
||||
|---|---|
|
||||
| 图片路径 | 写入 `skeleton.images` |
|
||||
| Spine 版本 | 固定 Spine 4.2 |
|
||||
| 关键帧插值曲线 | 线性或平滑贝塞尔 |
|
||||
| 导出 FPS | 默认 30 |
|
||||
| 不写 fps | 只省略元数据,不改变动画时间 |
|
||||
| 整数帧对齐 | 调整采样帧率时保持实际秒数不变 |
|
||||
| 骨骼对象池 | 默认开启,稳定复用粒子骨骼轨道 |
|
||||
| 排除无内容动画 | 按开关决定是否保留空动画 |
|
||||
|
||||
### 3.2 FPS 规则
|
||||
|
||||
修改导出 FPS 时保持动画实际秒数不变。
|
||||
|
||||
例如原动画为 30 FPS、30 帧,即 1 秒;改为 60 FPS 后会重采样到约 60 帧,仍保持 1 秒,而不是把 30 帧直接解释为 0.5 秒。
|
||||
|
||||
“不写 fps”只影响:
|
||||
|
||||
```text
|
||||
skeleton.fps
|
||||
```
|
||||
|
||||
不会改变关键帧的 `time` 秒数。
|
||||
|
||||
### 3.3 空动画
|
||||
|
||||
空动画是否保留完全遵循“排除无内容动画”设置。
|
||||
|
||||
关闭开关时,即使动画当前没有粒子时间轴,也保留空动画名称,方便后续在 Spine 工程内继续编辑或与其他动画合并。
|
||||
|
||||
---
|
||||
|
||||
## 四、导出骨骼层级
|
||||
|
||||
### 4.1 粒子系统父级
|
||||
|
||||
每个粒子系统建立一根系统骨骼,直接挂在 `root` 下:
|
||||
|
||||
```text
|
||||
root
|
||||
└─ ParticleSystem1
|
||||
```
|
||||
|
||||
粒子骨骼和拖尾骨骼全部直接挂在粒子系统骨骼下:
|
||||
|
||||
```text
|
||||
root
|
||||
└─ ParticleSystem1
|
||||
├─ _particle_000
|
||||
├─ _particle_000_trail_1_0
|
||||
├─ _particle_000_trail_1_1
|
||||
└─ _particle_000_trail_1_2
|
||||
```
|
||||
|
||||
不再使用以下错误结构:
|
||||
|
||||
```text
|
||||
root
|
||||
├─ ParticleSystem1_particle_000
|
||||
├─ ParticleSystem1_particle_001
|
||||
└─ ParticleSystem1_particle_002
|
||||
```
|
||||
|
||||
### 4.2 拖尾骨骼不组成骨骼链
|
||||
|
||||
早期导出把拖尾骨骼首尾相接:
|
||||
|
||||
```text
|
||||
trail_0
|
||||
└─ trail_1
|
||||
└─ trail_2
|
||||
```
|
||||
|
||||
这种结构会让子骨骼继承父骨骼的位置、旋转和缩放,导致世界变换逐级累积。网页画布与 Spine 编辑器中的拖尾表现因此不一致。
|
||||
|
||||
当前规则是所有拖尾骨骼同级:
|
||||
|
||||
```text
|
||||
ParticleSystem1
|
||||
├─ trail_0
|
||||
├─ trail_1
|
||||
└─ trail_2
|
||||
```
|
||||
|
||||
每根拖尾骨骼独立写入:
|
||||
|
||||
- 世界位置;
|
||||
- 世界旋转;
|
||||
- 宽度和形状需要的缩放。
|
||||
|
||||
导出校验器会阻止拖尾骨骼再次组成父子链。
|
||||
|
||||
### 4.3 设置姿态
|
||||
|
||||
所有系统骨骼和粒子骨骼的设置姿态保持安全默认值:
|
||||
|
||||
```text
|
||||
位置:0, 0
|
||||
旋转:0
|
||||
缩放:1, 1
|
||||
```
|
||||
|
||||
拖尾同级骨骼也从原点设置姿态开始,实际位置和旋转写入动画时间轴,避免设置姿态破坏 Mesh 蒙皮。
|
||||
|
||||
附件默认颜色使用透明状态,导入 Spine 后不会在设置模式中干扰已有图像显示。
|
||||
|
||||
---
|
||||
|
||||
## 五、粒子附件和动画
|
||||
|
||||
### 5.1 粒子图片
|
||||
|
||||
每根粒子骨骼根据图片混合模式创建相应插槽。支持:
|
||||
|
||||
- normal;
|
||||
- additive;
|
||||
- multiply;
|
||||
- screen。
|
||||
|
||||
固定图片使用区域附件。序列帧图片在同一插槽中通过 attachment 时间轴切换附件,不会为每张序列图创建新骨骼。
|
||||
|
||||
### 5.2 粒子动画时间轴
|
||||
|
||||
粒子骨骼导出:
|
||||
|
||||
- translate;
|
||||
- rotate;
|
||||
- scale。
|
||||
|
||||
粒子插槽导出:
|
||||
|
||||
- attachment;
|
||||
- rgba。
|
||||
|
||||
粒子出生、死亡、资源切换和序列帧切换会被识别为强制边界,关键帧精简不能跨过这些离散事件破坏状态。
|
||||
|
||||
### 5.3 骨骼对象池
|
||||
|
||||
开启骨骼对象池后,同一粒子池槽位在多个出生周期中复用同一根导出骨骼。粒子死亡阶段通过附件显隐和 RGBA 时间轴表达,而不是不断创建新的骨骼名称。
|
||||
|
||||
该规则显著降低高发射率粒子系统的骨骼数量,并让重新导入 Spine 后的层级更适合继续编辑。
|
||||
|
||||
---
|
||||
|
||||
## 六、关键帧精简
|
||||
|
||||
### 6.1 数据容差
|
||||
|
||||
阶段十一最终采用以下导出容差:
|
||||
|
||||
| 数据 | 容差 |
|
||||
|---|---:|
|
||||
| 粒子位置 | 0.1 像素 |
|
||||
| 拖尾骨骼位置 | 0.1 像素 |
|
||||
| 旋转 | 0.01° |
|
||||
| 缩放 | 0.01 |
|
||||
| 透明度 | 0.01 |
|
||||
| RGB | 单通道 1/255 |
|
||||
|
||||
这些数值用于判断中间关键帧是否可以由相邻关键帧安全重建。
|
||||
|
||||
### 6.2 线性模式
|
||||
|
||||
线性模式使用误差受控的数值关键帧精简。如果一段位置、旋转、缩放或颜色变化可以由线性插值在容差内还原,则只保留必要端点。
|
||||
|
||||
出生、死亡、附件切换和突变帧不会被普通线性精简跨越。
|
||||
|
||||
### 6.3 平滑贝塞尔
|
||||
|
||||
平滑贝塞尔模式对逐帧烘焙结果进行分段三次曲线拟合,并把控制值写入 Spine 曲线数据。
|
||||
|
||||
该模式主要用于:
|
||||
|
||||
- 曲线运动;
|
||||
- 非线性透明度;
|
||||
- 平滑颜色变化;
|
||||
- 受修改器影响的连续轨迹。
|
||||
|
||||
拟合不会过度激进,优先保留动画精度和 Spine 编辑器中的可编辑性。
|
||||
|
||||
### 6.4 旋转展开
|
||||
|
||||
旋转精简前会先展开跨越 `-180°/180°` 的角度序列,避免真实连续旋转被误判为反方向大跳变。
|
||||
|
||||
---
|
||||
|
||||
## 七、曲线编辑器
|
||||
|
||||
### 7.1 三种模式
|
||||
|
||||
带“曲线模式”的参数支持:
|
||||
|
||||
| 模式 | 行为 |
|
||||
|---|---|
|
||||
| 跟随导出 | 读取导出设置的关键帧插值模式 |
|
||||
| 线性 | 当前参数强制使用线性 |
|
||||
| 贝塞尔 | 当前参数强制使用贝塞尔 |
|
||||
|
||||
“跟随导出”是单向读取关系:
|
||||
|
||||
```text
|
||||
导出设置 → 当前曲线参数
|
||||
```
|
||||
|
||||
它不会反向修改导出设置。
|
||||
|
||||
### 7.2 贝塞尔控制手柄
|
||||
|
||||
曲线编辑器的贝塞尔模式现在会显示:
|
||||
|
||||
- 节点输入手柄;
|
||||
- 节点输出手柄;
|
||||
- 节点到手柄的辅助线;
|
||||
- 三次贝塞尔实际曲线路径。
|
||||
|
||||
手柄位置作为曲线点的相对数据保存:
|
||||
|
||||
```text
|
||||
inX / inY
|
||||
outX / outY
|
||||
```
|
||||
|
||||
这些字段自动进入配置保存、加载、预设和撤回快照。
|
||||
|
||||
未手动调整的贝塞尔手柄默认位于相邻节点三分之一位置,因此初始外观仍与线性一致,但用户可以拖动手柄改变曲率。
|
||||
|
||||
### 7.3 当前联动范围
|
||||
|
||||
当前已接入曲线模式解析的主要参数:
|
||||
|
||||
- 粒子系统透明度曲线;
|
||||
- 图片资源独立透明度曲线;
|
||||
- 碰撞对象路径跟随曲线。
|
||||
|
||||
粒子实时预览与导出使用同一套“跟随导出”解析结果。
|
||||
|
||||
---
|
||||
|
||||
## 八、拖尾加权 Mesh
|
||||
|
||||
### 8.1 固定拓扑
|
||||
|
||||
每个拖尾附件只建立一次固定拓扑 Mesh。生命周期中不会改变:
|
||||
|
||||
- 顶点数量;
|
||||
- UV 数量;
|
||||
- 三角形索引;
|
||||
- hull 顶点数量;
|
||||
- edges;
|
||||
- 顶点骨骼权重结构。
|
||||
|
||||
动画只改变拖尾骨骼和插槽数据,绝不在不同帧生成不同数量的网格顶点。
|
||||
|
||||
### 8.2 hull 与顶点顺序
|
||||
|
||||
Spine JSON 的 `hull` 表示顶点数组开头连续存放的外轮廓顶点数量。Mesh 构建器会先按顺时针顺序写入四周轮廓,再追加内部网格点。
|
||||
|
||||
该规则修复了早期导入 Spine 后出现的异常外轮廓、顶点连线和导入失败问题。
|
||||
|
||||
### 8.3 权重
|
||||
|
||||
每个网格列最多受相邻两根拖尾骨骼影响。权重之和严格为 1。
|
||||
|
||||
拖尾骨骼改成同级后,顶点局部位置不再依赖链式设置姿态偏移。每列中心主要由骨骼动画位置与相邻骨骼权重插值得到,网格宽度由骨骼 Y 缩放和局部横向顶点共同表达。
|
||||
|
||||
### 8.4 导入前校验
|
||||
|
||||
下载前会检查:
|
||||
|
||||
- UV 数量是否合法;
|
||||
- 顶点编码是否合法;
|
||||
- 三角形是否越界;
|
||||
- hull 是否越界;
|
||||
- edges 是否为有效偶数索引;
|
||||
- 骨骼索引是否存在;
|
||||
- 每个顶点权重之和是否为 1;
|
||||
- 加权 Mesh 是否使用了空附件;
|
||||
- 拖尾骨骼是否错误组成骨骼链。
|
||||
|
||||
随后使用项目内 Spine 4.2 Runtime 的 `SkeletonJson` 再解析一次,只有通过后才触发下载。
|
||||
|
||||
---
|
||||
|
||||
## 九、拖尾骨骼生命周期
|
||||
|
||||
### 9.1 整组出生和死亡
|
||||
|
||||
一个拖尾 Mesh 绑定的全部骨骼属于同一个固定骨骼组。只要该粒子生成拖尾,这组骨骼必须:
|
||||
|
||||
- 同一帧出生;
|
||||
- 同一帧进入活动状态;
|
||||
- 同步移动;
|
||||
- 同一帧死亡或假死。
|
||||
|
||||
禁止根据轨迹长度逐节激活骨骼。旧字段 `activeBoneCount` 只保留配置兼容意义,当前有效状态始终等于拖尾骨骼总数。
|
||||
|
||||
### 9.2 未完全展开时的位置
|
||||
|
||||
拖尾刚生成时,历史轨迹可能短于配置长度。当前规则不是把后方骨骼全部堆叠在最旧点,而是:
|
||||
|
||||
1. 从粒子出生点开始记录首个轨迹段;
|
||||
2. 计算当前已经积累的有效轨迹长度;
|
||||
3. 在有效轨迹内把全部拖尾骨骼等距分布;
|
||||
4. 随有效轨迹增长,整组骨骼逐渐展开到完整配置长度。
|
||||
|
||||
因此即使拖尾尚未达到完整长度,Mesh 也始终由完整骨骼组驱动。
|
||||
|
||||
### 9.3 未完全展开时的旋转
|
||||
|
||||
位置插值期间,旋转也必须平滑。
|
||||
|
||||
当前做法:
|
||||
|
||||
1. 计算头部切线方向;
|
||||
2. 计算当前尾端切线方向;
|
||||
3. 将角度差转换为最短旋转方向;
|
||||
4. 按骨骼序号在头尾角度之间插值;
|
||||
5. 达到完整轨迹长度后恢复按真实局部切线计算。
|
||||
|
||||
这样可以避免同级骨骼方向突然折跳造成网格不平滑。
|
||||
|
||||
缩放不参与这段“轨迹不足”的补间,继续由拖尾宽度和形状参数控制。
|
||||
|
||||
### 9.4 假死方式
|
||||
|
||||
早期方案把尚未出现或死亡的拖尾骨骼缩放到 0。整组骨骼生命周期统一后,不再需要逐骨骼缩放假死。
|
||||
|
||||
当前规则:
|
||||
|
||||
```text
|
||||
拖尾存在:插槽 alpha = 实际透明度
|
||||
拖尾不存在:插槽 alpha = 0
|
||||
```
|
||||
|
||||
骨骼缩放始终保留,原因是缩放还负责:
|
||||
|
||||
- 拖尾宽度;
|
||||
- 粒子宽度跟随;
|
||||
- 拖尾形状曲线;
|
||||
- 后续在 Spine 中继续编辑。
|
||||
|
||||
出生前使用首次有效骨骼姿态但插槽透明,死亡后保持最后有效骨骼姿态但插槽透明。Mesh 和骨骼对象不会在时间轴中删除和重建。
|
||||
|
||||
---
|
||||
|
||||
## 十、拖尾比例与随机尺寸
|
||||
|
||||
### 10.1 拖尾比例
|
||||
|
||||
每张拖尾资源卡片新增:
|
||||
|
||||
```text
|
||||
拖尾比例 (%)
|
||||
```
|
||||
|
||||
有效范围为 0~100:
|
||||
|
||||
| 数值 | 行为 |
|
||||
|---:|---|
|
||||
| 0 | 选中该资源的粒子均不生成拖尾 |
|
||||
| 50 | 约一半粒子生成拖尾 |
|
||||
| 100 | 所有选中该资源的粒子生成拖尾 |
|
||||
|
||||
判断发生在粒子出生时,使用粒子系统随机种子和周期内出生序号,因此相同配置与种子可以稳定复现。
|
||||
|
||||
当当前发射段计算出的粒子数量为 1、拖尾比例大于 0 时,第一颗粒子强制生成拖尾,避免开启拖尾却因概率采样完全看不到效果。比例明确设置为 0 时不执行保底。
|
||||
|
||||
### 10.2 长度和宽度模式
|
||||
|
||||
拖尾长度与宽度分别支持:
|
||||
|
||||
| 模式 | 参数 |
|
||||
|---|---|
|
||||
| 固定 | 单一比例值 |
|
||||
| 随机范围 | 最小比例和最大比例 |
|
||||
|
||||
默认值:
|
||||
|
||||
```text
|
||||
固定长度:100%
|
||||
固定宽度:100%
|
||||
随机最小:100%
|
||||
随机最大:100%
|
||||
```
|
||||
|
||||
尺寸解释:
|
||||
|
||||
| 数值 | 行为 |
|
||||
|---:|---|
|
||||
| 0 | 不生成拖尾 |
|
||||
| 100 | 使用标准长度或宽度 |
|
||||
| 200 | 标准尺寸的 2 倍 |
|
||||
|
||||
为支持超过 100 的比例放大,界面当前允许输入 0~500。
|
||||
|
||||
随机长度和宽度分别使用独立确定性采样,不会因为其他随机参数增加或删除而改变结果。
|
||||
|
||||
### 10.3 导出适配
|
||||
|
||||
不同粒子可以拥有不同拖尾长度和宽度:
|
||||
|
||||
- 长度通过每根拖尾骨骼的实际动画位置表达;
|
||||
- 宽度通过骨骼 Y 缩放表达;
|
||||
- 形状曲线继续叠加到各骨骼宽度缩放;
|
||||
- 没有生成拖尾的粒子不创建对应拖尾动画内容;
|
||||
- 某个资源在整条轨道中从未出现拖尾时,不创建无用 Mesh 和拖尾骨骼。
|
||||
|
||||
---
|
||||
|
||||
## 十一、第 0 帧与发射规则
|
||||
|
||||
### 11.1 普通发射模式
|
||||
|
||||
以下模式第 0 帧固定为空:
|
||||
|
||||
- 爆发;
|
||||
- 常规持续。
|
||||
|
||||
所有粒子发射行为从第 1 帧开始。第 0 帧画布中:
|
||||
|
||||
```text
|
||||
粒子数量 = 0
|
||||
粒子骨骼数量 = 0
|
||||
拖尾骨骼数量 = 0
|
||||
```
|
||||
|
||||
### 11.2 循环持续例外
|
||||
|
||||
循环持续不受普通第 0 帧空白规则约束,而是继续使用阶段十已经确定的三段逻辑:
|
||||
|
||||
| 循环状态 | 第 0 帧 |
|
||||
|---|---|
|
||||
| 生成开始循环动画 | 空帧 |
|
||||
| 纯循环 | 循环衔接帧,可包含骨骼 |
|
||||
| 生成结束循环动画 | 循环衔接帧,可包含骨骼 |
|
||||
|
||||
缓存生成层和画布显示层都按粒子系统分别判断,不会因为同一场景中存在普通系统而错误清空循环系统的衔接帧。
|
||||
|
||||
### 11.3 爆发重复修复
|
||||
|
||||
时间轴固定帧之间会调用:
|
||||
|
||||
```text
|
||||
update(0)
|
||||
```
|
||||
|
||||
用于保持当前画面而不推进时间。旧爆发判断使用:
|
||||
|
||||
```text
|
||||
burstTimer <= dt
|
||||
```
|
||||
|
||||
当 `dt = 0` 时,`0 <= 0` 被误认为第一次爆发,导致设置单次数量为 1 时实际出现 2 个粒子。
|
||||
|
||||
当前增加明确限制:
|
||||
|
||||
```text
|
||||
dt > 0
|
||||
```
|
||||
|
||||
零时间刷新不再触发发射。浏览器回归验证结果:
|
||||
|
||||
```text
|
||||
单次数量 = 1
|
||||
第 0 帧粒子 = 0
|
||||
第 1 帧粒子 = 1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十二、画布骨骼调试
|
||||
|
||||
### 12.1 骨骼方向轴
|
||||
|
||||
开启系统设置中的“骨骼连线”后,每个粒子骨骼和拖尾骨骼点都会显示局部方向轴:
|
||||
|
||||
| 颜色 | 含义 |
|
||||
|---|---|
|
||||
| 青色 | 骨骼正方向 |
|
||||
| 黄色 | 与正方向垂直的辅助轴 |
|
||||
|
||||
粒子方向轴会处理网页画布 Y 轴向下与数学旋转方向的差异;拖尾方向轴直接使用拖尾轨迹切线旋转。
|
||||
|
||||
画布中的拖尾连接线也已改为同级语义,不再把相邻拖尾骨骼画成父子链。
|
||||
|
||||
### 12.2 固定屏幕尺寸
|
||||
|
||||
骨骼点和方向轴的视觉尺寸不随画布缩放改变。绘制前先把目标屏幕像素除以当前画布缩放,确保放大或缩小画布后调试标记仍保持相同大小。
|
||||
|
||||
系统设置新增:
|
||||
|
||||
```text
|
||||
骨骼点绘制大小:默认 1
|
||||
坐标轴绘制大小:默认 2
|
||||
```
|
||||
|
||||
有效范围为 0.1~10。参数会进入:
|
||||
|
||||
- 配置保存;
|
||||
- 配置加载;
|
||||
- 预设;
|
||||
- 撤回快照。
|
||||
|
||||
旧配置缺少字段时自动恢复默认值。
|
||||
|
||||
---
|
||||
|
||||
## 十三、使用说明
|
||||
|
||||
画布右上角增加“使用说明”按钮。点击后显示可关闭的新手说明弹窗,内容包括:
|
||||
|
||||
- 创建和选择场景对象;
|
||||
- 粒子常用面板;
|
||||
- 时间轴播放和逐帧查看;
|
||||
- 图片、序列帧和拖尾资源;
|
||||
- 配置保存、加载和预设;
|
||||
- Spine JSON 导出;
|
||||
- 常用快捷键。
|
||||
|
||||
弹窗不会改变编辑器配置,也不会阻塞画布以外的配置数据。
|
||||
|
||||
---
|
||||
|
||||
## 十四、配置、撤回与兼容
|
||||
|
||||
阶段十一新增的重要配置字段包括:
|
||||
|
||||
```text
|
||||
曲线点:inX / inY / outX / outY
|
||||
系统设置:bonePointDrawSize / boneAxisDrawSize
|
||||
拖尾资源:trailRatio
|
||||
拖尾资源:lengthMode / lengthMin / lengthMax
|
||||
拖尾资源:widthSizeMode / widthMin / widthMax
|
||||
```
|
||||
|
||||
这些字段会自动进入编辑器 JSON 配置、预设、对象复制和撤回快照。
|
||||
|
||||
拖尾资源版本升级到:
|
||||
|
||||
```text
|
||||
trailResourceVersion = 6
|
||||
```
|
||||
|
||||
旧资源迁移规则:
|
||||
|
||||
- 缺少拖尾比例时使用 100;
|
||||
- 缺少长度模式时使用固定;
|
||||
- 长度随机范围初始化为旧固定长度;
|
||||
- 缺少宽度模式时使用固定;
|
||||
- 宽度随机范围初始化为旧固定宽度;
|
||||
- 旧配置已有的长度和宽度数值保持不变,不强制覆盖为 100;
|
||||
- 新建资源和重置资源使用 100 的新默认值。
|
||||
|
||||
---
|
||||
|
||||
## 十五、主要代码落点
|
||||
|
||||
| 文件 | 阶段十一职责 |
|
||||
|---|---|
|
||||
| `src/export/spineJsonExporter.ts` | Spine JSON 文档、骨骼层级、动画时间轴、拖尾整组显隐、文件下载 |
|
||||
| `src/export/spineKeyframeReducer.ts` | 线性与贝塞尔关键帧误差精简、控制值拟合、角度展开 |
|
||||
| `src/export/spineMeshBuilder.ts` | 同级拖尾骨骼加权 Mesh、UV、三角形、hull、edges 和顶点权重 |
|
||||
| `src/export/spineJsonValidator.ts` | Mesh 结构、拖尾层级与 Spine 4.2 Runtime 解析校验 |
|
||||
| `src/export/spineExportSettings.ts` | 导出参数默认值与旧设置补齐 |
|
||||
| `src/core/particleEmitter.ts` | 贝塞尔曲线采样、爆发修复、拖尾比例、随机尺寸、整组骨骼插值和录制状态 |
|
||||
| `src/views/CurveEditor.vue` | SVG 贝塞尔路径、输入/输出控制手柄和节点编辑 |
|
||||
| `src/views/ParticlePanel.vue` | 导出设置、拖尾资源比例、固定/随机尺寸、曲线模式与导出按钮 |
|
||||
| `src/views/Stage.vue` | 第 0 帧规则、循环例外、骨骼方向轴、固定调试尺寸、缓存录制和回放 |
|
||||
| `src/store/particleStore.ts` | 新系统设置字段和时间轴状态 |
|
||||
| `src/editor/editorConfig.ts` | 新字段保存、加载、旧配置补齐与纹理恢复 |
|
||||
| `导出设计标准规范.md` | Spine 导出长期标准与不可回退约束 |
|
||||
|
||||
---
|
||||
|
||||
## 十六、验证结果
|
||||
|
||||
### 16.1 构建
|
||||
|
||||
阶段结束执行:
|
||||
|
||||
```text
|
||||
npm run build
|
||||
```
|
||||
|
||||
结果:
|
||||
|
||||
- `vue-tsc -b` 通过;
|
||||
- Vite 生产构建通过;
|
||||
- 553 个模块完成转换;
|
||||
- 无 TypeScript 错误;
|
||||
- 无构建错误;
|
||||
- 仅保留既有的大包体积提示。
|
||||
|
||||
### 16.2 Spine JSON
|
||||
|
||||
使用 TrailTest 数据构造拖尾导出样例并通过项目内 Spine 4.2 Runtime 解析:
|
||||
|
||||
```text
|
||||
拖尾骨骼数量:3
|
||||
拖尾骨骼父级:全部为 ParticleSystem1
|
||||
动画数量:1
|
||||
Mesh 顶点和 UV:固定且匹配
|
||||
```
|
||||
|
||||
整组假死验证:
|
||||
|
||||
```text
|
||||
骨骼缩放归零:false
|
||||
插槽透明度:00 → bf → 00
|
||||
```
|
||||
|
||||
即骨骼缩放完整保留,拖尾通过统一插槽透明度出现和消失。
|
||||
|
||||
### 16.3 浏览器交互
|
||||
|
||||
已验证:
|
||||
|
||||
- 贝塞尔模式显示控制手柄;
|
||||
- 跟随导出 + 线性时不显示贝塞尔手柄;
|
||||
- 跟随导出 + 平滑贝塞尔时显示手柄;
|
||||
- 第 0 帧普通粒子数量为 0;
|
||||
- 爆发数量 1 时第 1 帧粒子数量为 1;
|
||||
- 骨骼方向轴正常显示;
|
||||
- 骨骼点绘制大小默认 1;
|
||||
- 坐标轴绘制大小默认 2;
|
||||
- 拖尾比例默认 100;
|
||||
- 长度和宽度可切换固定与随机范围;
|
||||
- 随机范围初值正确继承当前固定值。
|
||||
|
||||
---
|
||||
|
||||
## 十七、阶段十一最终状态
|
||||
|
||||
阶段十一完成后,编辑器已经具备:
|
||||
|
||||
- 可重新导入 Spine 4.2 编辑器的粒子 JSON;
|
||||
- 可编辑粒子骨骼、插槽和附件;
|
||||
- 固定拓扑加权拖尾 Mesh;
|
||||
- 同级拖尾骨骼结构;
|
||||
- 整组拖尾骨骼生命周期;
|
||||
- 插槽透明度假死;
|
||||
- 未展开拖尾的位置与旋转平滑插值;
|
||||
- 误差受控的关键帧精简;
|
||||
- 线性和贝塞尔导出模式;
|
||||
- 可操作的贝塞尔曲线手柄;
|
||||
- 普通发射第 0 帧空白规则;
|
||||
- 循环持续独立第 0 帧衔接规则;
|
||||
- 正确的单次爆发数量;
|
||||
- 固定屏幕尺寸的骨骼点与方向轴;
|
||||
- 拖尾比例与确定性随机长度、宽度;
|
||||
- 配置、预设、复制和撤回完整联动;
|
||||
- 下载前 Spine 4.2 结构校验。
|
||||
|
||||
至此,阶段十一“Spine JSON 导出体系与拖尾蒙皮定版”完成落盘。
|
||||