开发中途存档
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
[
|
||||
{
|
||||
"name": "空场景",
|
||||
"file": "空场景.json"
|
||||
},
|
||||
{
|
||||
"name": "粒子+碰撞+路径",
|
||||
"file": "粒子+碰撞+路径.json"
|
||||
},
|
||||
{
|
||||
"name": "默认粒子",
|
||||
"file": "默认粒子.json"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"format": "SpineParticle.EditorConfig",
|
||||
"version": 1,
|
||||
"savedAt": "2026-08-29T00:00:00.000Z",
|
||||
"editor": {
|
||||
"viewScale": 1,
|
||||
"viewX": 0,
|
||||
"viewY": 0
|
||||
},
|
||||
"settings": {
|
||||
"tickEnabled": false,
|
||||
"axisFontSize": 12,
|
||||
"tickWidth": 1,
|
||||
"showAxisLabels": true,
|
||||
"axisColor": "#7a86ad",
|
||||
"undoLimit": 20,
|
||||
"backgroundX": 0,
|
||||
"backgroundY": 0,
|
||||
"backgroundOpacity": 1,
|
||||
"backgroundImageName": "",
|
||||
"backgroundImageData": "",
|
||||
"showEmitter": true,
|
||||
"showBones": false,
|
||||
"showSkinMesh": false,
|
||||
"showGrid": true
|
||||
},
|
||||
"timeline": {
|
||||
"fps": 30,
|
||||
"loop": true,
|
||||
"animations": ["animation"]
|
||||
},
|
||||
"selection": {
|
||||
"type": "particle",
|
||||
"id": 0
|
||||
},
|
||||
"scene": {
|
||||
"systems": [],
|
||||
"colliders": [],
|
||||
"paths": [],
|
||||
"spines": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
{
|
||||
"format": "SpineParticle.EditorConfig",
|
||||
"version": 1,
|
||||
"savedAt": "2026-08-29T14:27:33.380Z",
|
||||
"editor": {
|
||||
"viewScale": 1.12,
|
||||
"viewX": -7.944298618861609,
|
||||
"viewY": -12.270927472795819
|
||||
},
|
||||
"settings": {
|
||||
"tickEnabled": false,
|
||||
"axisFontSize": 12,
|
||||
"tickWidth": 1,
|
||||
"showAxisLabels": true,
|
||||
"axisColor": "#7a86ad",
|
||||
"undoLimit": 20,
|
||||
"backgroundX": 0,
|
||||
"backgroundY": 0,
|
||||
"backgroundOpacity": 1,
|
||||
"backgroundImageName": "",
|
||||
"backgroundImageData": "",
|
||||
"showEmitter": true,
|
||||
"showBones": false,
|
||||
"showSkinMesh": false,
|
||||
"showGrid": true
|
||||
},
|
||||
"timeline": {
|
||||
"fps": 30,
|
||||
"loop": true,
|
||||
"animations": [
|
||||
"animation"
|
||||
]
|
||||
},
|
||||
"selection": {
|
||||
"type": "particle",
|
||||
"id": 2
|
||||
},
|
||||
"scene": {
|
||||
"systems": [
|
||||
{
|
||||
"id": 2,
|
||||
"visible": true,
|
||||
"animation": "animation",
|
||||
"config": {
|
||||
"imageResources": [
|
||||
{
|
||||
"id": 1,
|
||||
"textureName": "star",
|
||||
"textureFolder": "",
|
||||
"previewUrl": "/particles/star.png",
|
||||
"weight": 100,
|
||||
"locked": false,
|
||||
"scale": 1,
|
||||
"anchorX": 0.5,
|
||||
"anchorY": 0.5,
|
||||
"colorMode": "fixed",
|
||||
"colorStart": "#ffffff",
|
||||
"colorEnd": "#ff4d4d",
|
||||
"colorGradient": [
|
||||
{
|
||||
"position": 0,
|
||||
"color": "#ffffff"
|
||||
},
|
||||
{
|
||||
"position": 1,
|
||||
"color": "#ff4d4d"
|
||||
}
|
||||
],
|
||||
"blend": "normal",
|
||||
"independentAlpha": "off",
|
||||
"alpha": 1,
|
||||
"alphaCurveMode": "跟随导出",
|
||||
"alphaCurve": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
{
|
||||
"x": 0.1,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 0.9,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"imageWeightVersion": 1,
|
||||
"name": "ParticleSystem1",
|
||||
"mode": "stream",
|
||||
"rate": 20,
|
||||
"burstCount": 50,
|
||||
"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": 0.6,
|
||||
"lifeMax": 1.8,
|
||||
"lifeMode": "random",
|
||||
"lifeCurve": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1
|
||||
}
|
||||
],
|
||||
"speedMin": 30,
|
||||
"speedMax": 140,
|
||||
"speedMode": "random",
|
||||
"speedCurve": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1
|
||||
}
|
||||
],
|
||||
"scaleMin": 0.3,
|
||||
"scaleMax": 0.8,
|
||||
"scaleMode": "random",
|
||||
"scaleAxisMode": "uniform",
|
||||
"scaleYMin": 0.3,
|
||||
"scaleYMax": 0.8,
|
||||
"scaleCurve": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1
|
||||
}
|
||||
],
|
||||
"scaleYCurve": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1
|
||||
}
|
||||
],
|
||||
"scaleEndRatio": 0.3,
|
||||
"initialRotationMode": "random",
|
||||
"initialRotationMin": 0,
|
||||
"initialRotationMax": 360,
|
||||
"initialRotationCurve": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1
|
||||
}
|
||||
],
|
||||
"alpha": 0.9,
|
||||
"alphaEnd": 0,
|
||||
"rotationSpeedMin": -180,
|
||||
"rotationSpeedMax": 180,
|
||||
"speedOverLifeEnabled": false,
|
||||
"speedOverLifeMode": "fixed",
|
||||
"speedOverLifeMin": 1,
|
||||
"speedOverLifeMax": 1,
|
||||
"speedOverLifeCurve": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1
|
||||
}
|
||||
],
|
||||
"scaleOverLifeEnabled": false,
|
||||
"scaleOverLifeMode": "curve",
|
||||
"scaleOverLifeAxisMode": "uniform",
|
||||
"scaleOverLifeMin": 0,
|
||||
"scaleOverLifeMax": 1,
|
||||
"scaleYOverLifeMin": 0,
|
||||
"scaleYOverLifeMax": 1,
|
||||
"scaleOverLifeCurve": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1
|
||||
}
|
||||
],
|
||||
"scaleYOverLifeCurve": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1
|
||||
}
|
||||
],
|
||||
"rotationOverLifeEnabled": false,
|
||||
"rotationOverLifeMode": "random",
|
||||
"rotationOverLifeCurve": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1
|
||||
}
|
||||
],
|
||||
"gravity": 980,
|
||||
"gravityMode": "fixed",
|
||||
"gravityMin": 980,
|
||||
"gravityMax": 980,
|
||||
"gravityDirection": -90,
|
||||
"gravityCurve": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1
|
||||
}
|
||||
],
|
||||
"damping": 0,
|
||||
"seed": 0,
|
||||
"colorStart": "#ffffff",
|
||||
"colorEnd": "#ff4d4d",
|
||||
"colorOverLife": false,
|
||||
"blend": "add",
|
||||
"alphaMode": "curve",
|
||||
"curveMode": "线性",
|
||||
"alphaCurve": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
{
|
||||
"x": 0.2,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 0.8,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 0
|
||||
}
|
||||
],
|
||||
"textureName": "star",
|
||||
"textureFolder": "",
|
||||
"alphaScale": 1,
|
||||
"anchorX": 0.5,
|
||||
"anchorY": 0.5,
|
||||
"independentAlpha": "off",
|
||||
"offsetX": 0,
|
||||
"offsetY": 0,
|
||||
"noise": false,
|
||||
"noiseAmt": 30,
|
||||
"noiseRange": 100,
|
||||
"noiseSpeed": 1,
|
||||
"useGravity": false,
|
||||
"wind": false,
|
||||
"windX": 0,
|
||||
"windY": 0,
|
||||
"windMode": "fixed",
|
||||
"windMin": 100,
|
||||
"windMax": 100,
|
||||
"windDirection": 0,
|
||||
"windCurve": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1
|
||||
}
|
||||
],
|
||||
"forceField": false,
|
||||
"forceStrength": 10,
|
||||
"forceRadius": 100,
|
||||
"forceCenterX": 0,
|
||||
"forceCenterY": 0,
|
||||
"forceFalloff": "linear",
|
||||
"attraction": false,
|
||||
"attractionCenterX": 200,
|
||||
"attractionCenterY": 0,
|
||||
"attractionRadius": 20,
|
||||
"attractionDuration": 0.5,
|
||||
"attractionDelay": 0.1,
|
||||
"attractionDelayMode": "particle",
|
||||
"attractionPath": "curve",
|
||||
"trail": false,
|
||||
"trailConfigVersion": 3,
|
||||
"trailResources": [
|
||||
{
|
||||
"id": 1,
|
||||
"textureName": "trail",
|
||||
"previewUrl": "/trails/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",
|
||||
"lifeLengthEnabled": false,
|
||||
"lifeLengthCurve": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1
|
||||
}
|
||||
],
|
||||
"shapeEnabled": false,
|
||||
"shapeCurve": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"trailResourceVersion": 5,
|
||||
"trailTextureName": "trail",
|
||||
"trailPreviewUrl": "/trails/trail.png",
|
||||
"trailColorMode": "particle",
|
||||
"trailColor": "#ffffff",
|
||||
"trailColorGradient": [
|
||||
{
|
||||
"position": 0,
|
||||
"color": "#ffffff"
|
||||
},
|
||||
{
|
||||
"position": 1,
|
||||
"color": "#ffffff"
|
||||
}
|
||||
],
|
||||
"trailAlphaMode": "particle",
|
||||
"trailAlpha": 1,
|
||||
"trailAlphaCurve": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1
|
||||
}
|
||||
],
|
||||
"trailBoneCount": 3,
|
||||
"trailLength": 100,
|
||||
"trailWidth": 100,
|
||||
"trailWidthMode": "particle",
|
||||
"trailLifeLengthEnabled": false,
|
||||
"trailLifeLengthCurve": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1
|
||||
}
|
||||
],
|
||||
"trailShapeEnabled": false,
|
||||
"trailShapeCurve": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1
|
||||
}
|
||||
],
|
||||
"trailGridRows": 4,
|
||||
"trailGridCols": 4,
|
||||
"collision": false,
|
||||
"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": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Collider1",
|
||||
"visible": true,
|
||||
"tags": [
|
||||
"Default"
|
||||
],
|
||||
"selectedTag": "Default",
|
||||
"shape": "circle",
|
||||
"enabled": true,
|
||||
"x": -86.04703630719865,
|
||||
"y": -9.03423854282924,
|
||||
"rotation": 0,
|
||||
"scaleX": 1,
|
||||
"scaleY": 1,
|
||||
"radius": 50,
|
||||
"radiusX": 70,
|
||||
"radiusY": 45,
|
||||
"width": 120,
|
||||
"height": 80,
|
||||
"sides": 6,
|
||||
"response": "physics",
|
||||
"elasticity": 0.8,
|
||||
"friction": 0.3,
|
||||
"followPath": false,
|
||||
"followPathId": 0,
|
||||
"followDuration": 2,
|
||||
"followPathRotation": false,
|
||||
"followCurveMode": "跟随导出",
|
||||
"followCurve": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"paths": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Path1",
|
||||
"visible": true,
|
||||
"enabled": true,
|
||||
"x": 198.7164088657924,
|
||||
"y": 4.279381888253348,
|
||||
"rotation": 0,
|
||||
"scaleX": 1,
|
||||
"scaleY": 1,
|
||||
"closed": true,
|
||||
"color": "#ff933e",
|
||||
"preset": "circle",
|
||||
"points": [
|
||||
{
|
||||
"x": 110,
|
||||
"y": 0,
|
||||
"inX": 0,
|
||||
"inY": -60.751328,
|
||||
"outX": 0,
|
||||
"outY": 60.751328
|
||||
},
|
||||
{
|
||||
"x": 0,
|
||||
"y": 110,
|
||||
"inX": 60.751328,
|
||||
"inY": 0,
|
||||
"outX": -60.751328,
|
||||
"outY": 0
|
||||
},
|
||||
{
|
||||
"x": -110,
|
||||
"y": 0,
|
||||
"inX": 0,
|
||||
"inY": 60.751328,
|
||||
"outX": 0,
|
||||
"outY": -60.751328
|
||||
},
|
||||
{
|
||||
"x": 0,
|
||||
"y": -110,
|
||||
"inX": -60.751328,
|
||||
"inY": 0,
|
||||
"outX": 60.751328,
|
||||
"outY": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"spines": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
{
|
||||
"format": "SpineParticle.EditorConfig",
|
||||
"version": 1,
|
||||
"savedAt": "2026-08-29T14:25:58.695Z",
|
||||
"editor": {
|
||||
"viewScale": 1.12,
|
||||
"viewX": -7.944298618861609,
|
||||
"viewY": -12.270927472795819
|
||||
},
|
||||
"settings": {
|
||||
"tickEnabled": false,
|
||||
"axisFontSize": 12,
|
||||
"tickWidth": 1,
|
||||
"showAxisLabels": true,
|
||||
"axisColor": "#7a86ad",
|
||||
"undoLimit": 20,
|
||||
"backgroundX": 0,
|
||||
"backgroundY": 0,
|
||||
"backgroundOpacity": 1,
|
||||
"backgroundImageName": "",
|
||||
"backgroundImageData": "",
|
||||
"showEmitter": true,
|
||||
"showBones": false,
|
||||
"showSkinMesh": false,
|
||||
"showGrid": true
|
||||
},
|
||||
"timeline": {
|
||||
"fps": 30,
|
||||
"loop": true,
|
||||
"animations": [
|
||||
"animation"
|
||||
]
|
||||
},
|
||||
"selection": {
|
||||
"type": "particle",
|
||||
"id": 2
|
||||
},
|
||||
"scene": {
|
||||
"systems": [
|
||||
{
|
||||
"id": 2,
|
||||
"visible": true,
|
||||
"animation": "animation",
|
||||
"config": {
|
||||
"imageResources": [
|
||||
{
|
||||
"id": 1,
|
||||
"textureName": "star",
|
||||
"textureFolder": "",
|
||||
"previewUrl": "/particles/star.png",
|
||||
"weight": 100,
|
||||
"locked": false,
|
||||
"scale": 1,
|
||||
"anchorX": 0.5,
|
||||
"anchorY": 0.5,
|
||||
"colorMode": "fixed",
|
||||
"colorStart": "#ffffff",
|
||||
"colorEnd": "#ff4d4d",
|
||||
"colorGradient": [
|
||||
{
|
||||
"position": 0,
|
||||
"color": "#ffffff"
|
||||
},
|
||||
{
|
||||
"position": 1,
|
||||
"color": "#ff4d4d"
|
||||
}
|
||||
],
|
||||
"blend": "normal",
|
||||
"independentAlpha": "off",
|
||||
"alpha": 1,
|
||||
"alphaCurveMode": "跟随导出",
|
||||
"alphaCurve": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
{
|
||||
"x": 0.1,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 0.9,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"imageWeightVersion": 1,
|
||||
"name": "ParticleSystem1",
|
||||
"mode": "stream",
|
||||
"rate": 20,
|
||||
"burstCount": 50,
|
||||
"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": 0.6,
|
||||
"lifeMax": 1.8,
|
||||
"lifeMode": "random",
|
||||
"lifeCurve": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1
|
||||
}
|
||||
],
|
||||
"speedMin": 30,
|
||||
"speedMax": 140,
|
||||
"speedMode": "random",
|
||||
"speedCurve": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1
|
||||
}
|
||||
],
|
||||
"scaleMin": 0.3,
|
||||
"scaleMax": 0.8,
|
||||
"scaleMode": "random",
|
||||
"scaleAxisMode": "uniform",
|
||||
"scaleYMin": 0.3,
|
||||
"scaleYMax": 0.8,
|
||||
"scaleCurve": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1
|
||||
}
|
||||
],
|
||||
"scaleYCurve": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1
|
||||
}
|
||||
],
|
||||
"scaleEndRatio": 0.3,
|
||||
"initialRotationMode": "random",
|
||||
"initialRotationMin": 0,
|
||||
"initialRotationMax": 360,
|
||||
"initialRotationCurve": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1
|
||||
}
|
||||
],
|
||||
"alpha": 0.9,
|
||||
"alphaEnd": 0,
|
||||
"rotationSpeedMin": -180,
|
||||
"rotationSpeedMax": 180,
|
||||
"speedOverLifeEnabled": false,
|
||||
"speedOverLifeMode": "fixed",
|
||||
"speedOverLifeMin": 1,
|
||||
"speedOverLifeMax": 1,
|
||||
"speedOverLifeCurve": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1
|
||||
}
|
||||
],
|
||||
"scaleOverLifeEnabled": false,
|
||||
"scaleOverLifeMode": "curve",
|
||||
"scaleOverLifeAxisMode": "uniform",
|
||||
"scaleOverLifeMin": 0,
|
||||
"scaleOverLifeMax": 1,
|
||||
"scaleYOverLifeMin": 0,
|
||||
"scaleYOverLifeMax": 1,
|
||||
"scaleOverLifeCurve": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1
|
||||
}
|
||||
],
|
||||
"scaleYOverLifeCurve": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1
|
||||
}
|
||||
],
|
||||
"rotationOverLifeEnabled": false,
|
||||
"rotationOverLifeMode": "random",
|
||||
"rotationOverLifeCurve": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1
|
||||
}
|
||||
],
|
||||
"gravity": 980,
|
||||
"gravityMode": "fixed",
|
||||
"gravityMin": 980,
|
||||
"gravityMax": 980,
|
||||
"gravityDirection": -90,
|
||||
"gravityCurve": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1
|
||||
}
|
||||
],
|
||||
"damping": 0,
|
||||
"seed": 0,
|
||||
"colorStart": "#ffffff",
|
||||
"colorEnd": "#ff4d4d",
|
||||
"colorOverLife": false,
|
||||
"blend": "add",
|
||||
"alphaMode": "curve",
|
||||
"curveMode": "线性",
|
||||
"alphaCurve": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
{
|
||||
"x": 0.2,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 0.8,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 0
|
||||
}
|
||||
],
|
||||
"textureName": "star",
|
||||
"textureFolder": "",
|
||||
"alphaScale": 1,
|
||||
"anchorX": 0.5,
|
||||
"anchorY": 0.5,
|
||||
"independentAlpha": "off",
|
||||
"offsetX": 0,
|
||||
"offsetY": 0,
|
||||
"noise": false,
|
||||
"noiseAmt": 30,
|
||||
"noiseRange": 100,
|
||||
"noiseSpeed": 1,
|
||||
"useGravity": false,
|
||||
"wind": false,
|
||||
"windX": 0,
|
||||
"windY": 0,
|
||||
"windMode": "fixed",
|
||||
"windMin": 100,
|
||||
"windMax": 100,
|
||||
"windDirection": 0,
|
||||
"windCurve": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1
|
||||
}
|
||||
],
|
||||
"forceField": false,
|
||||
"forceStrength": 10,
|
||||
"forceRadius": 100,
|
||||
"forceCenterX": 0,
|
||||
"forceCenterY": 0,
|
||||
"forceFalloff": "linear",
|
||||
"attraction": false,
|
||||
"attractionCenterX": 200,
|
||||
"attractionCenterY": 0,
|
||||
"attractionRadius": 20,
|
||||
"attractionDuration": 0.5,
|
||||
"attractionDelay": 0.1,
|
||||
"attractionDelayMode": "particle",
|
||||
"attractionPath": "curve",
|
||||
"trail": false,
|
||||
"trailConfigVersion": 3,
|
||||
"trailResources": [
|
||||
{
|
||||
"id": 1,
|
||||
"textureName": "trail",
|
||||
"previewUrl": "/trails/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",
|
||||
"lifeLengthEnabled": false,
|
||||
"lifeLengthCurve": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1
|
||||
}
|
||||
],
|
||||
"shapeEnabled": false,
|
||||
"shapeCurve": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"trailResourceVersion": 5,
|
||||
"trailTextureName": "trail",
|
||||
"trailPreviewUrl": "/trails/trail.png",
|
||||
"trailColorMode": "particle",
|
||||
"trailColor": "#ffffff",
|
||||
"trailColorGradient": [
|
||||
{
|
||||
"position": 0,
|
||||
"color": "#ffffff"
|
||||
},
|
||||
{
|
||||
"position": 1,
|
||||
"color": "#ffffff"
|
||||
}
|
||||
],
|
||||
"trailAlphaMode": "particle",
|
||||
"trailAlpha": 1,
|
||||
"trailAlphaCurve": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1
|
||||
}
|
||||
],
|
||||
"trailBoneCount": 3,
|
||||
"trailLength": 100,
|
||||
"trailWidth": 100,
|
||||
"trailWidthMode": "particle",
|
||||
"trailLifeLengthEnabled": false,
|
||||
"trailLifeLengthCurve": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1
|
||||
}
|
||||
],
|
||||
"trailShapeEnabled": false,
|
||||
"trailShapeCurve": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1
|
||||
}
|
||||
],
|
||||
"trailGridRows": 4,
|
||||
"trailGridCols": 4,
|
||||
"collision": false,
|
||||
"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": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* 深拷贝编辑器中的普通配置对象和数组。
|
||||
* Pixi Texture 等运行时类实例作为只读资源句柄保留共享引用。
|
||||
*/
|
||||
export function cloneEditorValue<T>(value: T): T {
|
||||
if (Array.isArray(value)) return value.map((item) => cloneEditorValue(item)) as T
|
||||
if (value && typeof value === 'object') {
|
||||
const prototype = Object.getPrototypeOf(value)
|
||||
if (prototype === Object.prototype || prototype === null) {
|
||||
const copy: Record<string, unknown> = {}
|
||||
for (const [key, item] of Object.entries(value)) copy[key] = cloneEditorValue(item)
|
||||
return copy as T
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { markRaw } from 'vue'
|
||||
import { Texture } from 'pixi.js'
|
||||
import { ensureEmitterConfig } from '../core/particleEmitter'
|
||||
import type {
|
||||
CollisionBody,
|
||||
EditorState,
|
||||
ParticleSystem,
|
||||
SceneObjectType,
|
||||
ScenePath,
|
||||
SettingsState,
|
||||
useParticleStore,
|
||||
} from '../store/particleStore'
|
||||
import type { SpineSceneObject } from '../spine/spineTypes'
|
||||
import { parseSpineBundle, type ParsedSpineBundle } from '../spine/spineAssetLoader'
|
||||
import { restoreSpineSource } from '../spine/spineSourceBundle'
|
||||
import { registerSpineAsset, releaseSpineAsset } from '../spine/spineAssetRegistry'
|
||||
import { cloneEditorValue } from './editorClone'
|
||||
|
||||
type EditorStore = ReturnType<typeof useParticleStore>
|
||||
|
||||
export const EDITOR_CONFIG_FORMAT = 'SpineParticle.EditorConfig'
|
||||
export const EDITOR_CONFIG_VERSION = 1
|
||||
|
||||
export interface EditorConfigFile {
|
||||
format: typeof EDITOR_CONFIG_FORMAT
|
||||
version: number
|
||||
savedAt: string
|
||||
editor: EditorState
|
||||
settings: SettingsState
|
||||
timeline: {
|
||||
fps: number
|
||||
loop: boolean
|
||||
animations: string[]
|
||||
}
|
||||
selection: {
|
||||
type: SceneObjectType
|
||||
id: number
|
||||
}
|
||||
scene: {
|
||||
systems: ParticleSystem[]
|
||||
colliders: CollisionBody[]
|
||||
paths: ScenePath[]
|
||||
spines: SpineSceneObject[]
|
||||
}
|
||||
}
|
||||
|
||||
function jsonClone<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value, (key, item) => {
|
||||
if (key === 'texture' || key === 'trailTexture' || key === 'frames') return undefined
|
||||
return item
|
||||
})) as T
|
||||
}
|
||||
|
||||
export function createEditorConfig(store: EditorStore): EditorConfigFile {
|
||||
return jsonClone({
|
||||
format: EDITOR_CONFIG_FORMAT,
|
||||
version: EDITOR_CONFIG_VERSION,
|
||||
savedAt: new Date().toISOString(),
|
||||
editor: cloneEditorValue(store.editor),
|
||||
settings: cloneEditorValue(store.settings),
|
||||
timeline: {
|
||||
fps: store.timeline.fps,
|
||||
loop: store.timeline.loop,
|
||||
animations: [...store.timeline.animations],
|
||||
},
|
||||
selection: {
|
||||
type: store.activeObjectType,
|
||||
id: store.activeObjectId,
|
||||
},
|
||||
scene: {
|
||||
systems: store.systems.map((system) => ({ ...cloneEditorValue(system), frames: undefined })) as unknown as ParticleSystem[],
|
||||
colliders: cloneEditorValue(store.colliders) as unknown as CollisionBody[],
|
||||
paths: cloneEditorValue(store.paths) as unknown as ScenePath[],
|
||||
spines: cloneEditorValue(store.spines) as unknown as SpineSceneObject[],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function downloadName() {
|
||||
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, '').replace('T', '-')
|
||||
return `SpineParticle-${stamp}.json`
|
||||
}
|
||||
|
||||
export function downloadEditorConfig(store: EditorStore) {
|
||||
const config = createEditorConfig(store)
|
||||
const blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = downloadName()
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
anchor.remove()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0)
|
||||
return config
|
||||
}
|
||||
|
||||
export function parseEditorConfig(text: string): EditorConfigFile {
|
||||
let config: unknown
|
||||
try {
|
||||
config = JSON.parse(text)
|
||||
} catch {
|
||||
throw new Error('配置文件不是有效的 JSON')
|
||||
}
|
||||
const candidate = config as Partial<EditorConfigFile>
|
||||
if (candidate.format !== EDITOR_CONFIG_FORMAT) throw new Error('不是 SpineParticle 编辑器配置文件')
|
||||
if (candidate.version !== EDITOR_CONFIG_VERSION) throw new Error(`暂不支持配置版本:${candidate.version ?? '未知'}`)
|
||||
if (!candidate.scene || !Array.isArray(candidate.scene.systems) || !Array.isArray(candidate.scene.colliders)
|
||||
|| !Array.isArray(candidate.scene.paths) || !Array.isArray(candidate.scene.spines)) {
|
||||
throw new Error('配置文件缺少完整场景数据')
|
||||
}
|
||||
return candidate as EditorConfigFile
|
||||
}
|
||||
|
||||
async function hydrateParticleResources(systems: ParticleSystem[]) {
|
||||
const textureCache = new Map<string, Promise<Texture>>()
|
||||
const loadTexture = (url: string) => {
|
||||
const source = url || '/particles/star.png'
|
||||
let pending = textureCache.get(source)
|
||||
if (!pending) {
|
||||
pending = Texture.fromURL(source).then((texture) => markRaw(texture))
|
||||
textureCache.set(source, pending)
|
||||
}
|
||||
return pending
|
||||
}
|
||||
const defaultTexture = await loadTexture('/particles/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')
|
||||
}))
|
||||
await Promise.all(config.trailResources.map(async (resource) => {
|
||||
resource.texture = await loadTexture(resource.previewUrl || '/trails/trail.png')
|
||||
}))
|
||||
config.trailTexture = config.trailResources[0]?.texture || null
|
||||
system.frames = []
|
||||
}))
|
||||
}
|
||||
|
||||
async function parseEmbeddedSpines(spines: SpineSceneObject[]) {
|
||||
const parsed = new Map<number, ParsedSpineBundle>()
|
||||
try {
|
||||
for (const spine of spines) {
|
||||
if (!spine.sourceBundle) continue
|
||||
const source = await restoreSpineSource(spine.sourceBundle)
|
||||
parsed.set(spine.id, await parseSpineBundle(source.skeleton, source.atlas, source.textures))
|
||||
}
|
||||
return parsed
|
||||
} catch (error) {
|
||||
for (const asset of parsed.values()) for (const texture of asset.textures) texture.destroy(true)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function repairSelection(store: EditorStore, requested?: EditorConfigFile['selection']) {
|
||||
const type = requested?.type || 'particle'
|
||||
const id = Number(requested?.id) || 0
|
||||
const exists = type === 'particle' ? store.systems.some((item) => item.id === id)
|
||||
: type === 'collision' ? store.colliders.some((item) => item.id === id)
|
||||
: type === 'path' ? store.paths.some((item) => item.id === id)
|
||||
: store.spines.some((item) => item.id === id)
|
||||
if (exists) store.selectSceneObject(type, id)
|
||||
else if (store.systems[0]) store.selectSceneObject('particle', store.systems[0].id)
|
||||
else if (store.colliders[0]) store.selectSceneObject('collision', store.colliders[0].id)
|
||||
else if (store.paths[0]) store.selectSceneObject('path', store.paths[0].id)
|
||||
else if (store.spines[0]) store.selectSceneObject('spine', store.spines[0].id)
|
||||
else {
|
||||
store.activeObjectType = 'particle'
|
||||
store.activeObjectId = 0
|
||||
store.activeId = 0
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyEditorConfig(store: EditorStore, input: EditorConfigFile) {
|
||||
const config = parseEditorConfig(JSON.stringify(input))
|
||||
const systems = cloneEditorValue(config.scene.systems) as unknown as ParticleSystem[]
|
||||
const colliders = cloneEditorValue(config.scene.colliders) as unknown as CollisionBody[]
|
||||
const paths = cloneEditorValue(config.scene.paths) as unknown as ScenePath[]
|
||||
const spines = cloneEditorValue(config.scene.spines) as unknown as SpineSceneObject[]
|
||||
for (const spine of spines) if (spine.sourceBundle == null) spine.sourceBundle = null
|
||||
|
||||
await hydrateParticleResources(systems)
|
||||
const parsedSpines = await parseEmbeddedSpines(spines)
|
||||
const oldSpineVersions = new Map(store.spines.map((spine) => [spine.id, spine.assetVersion]))
|
||||
|
||||
for (const spine of store.spines) releaseSpineAsset(spine.id)
|
||||
store.systems = systems
|
||||
store.colliders = colliders
|
||||
store.paths = paths
|
||||
store.spines = spines
|
||||
Object.assign(store.editor, cloneEditorValue(config.editor || {}))
|
||||
Object.assign(store.settings, cloneEditorValue(config.settings || {}))
|
||||
store.settings.undoLimit = Math.min(200, Math.max(1, Math.round(Number(store.settings.undoLimit) || 20)))
|
||||
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
|
||||
? [...new Set(config.timeline.animations.filter((name) => typeof name === 'string' && name.trim()).map((name) => name.trim()))]
|
||||
: ['animation']
|
||||
|
||||
for (const spine of store.spines) {
|
||||
const parsed = parsedSpines.get(spine.id)
|
||||
spine.assetVersion = Math.max(Number(spine.assetVersion) || 0, oldSpineVersions.get(spine.id) || 0) + 1
|
||||
if (!parsed) {
|
||||
spine.status = 'empty'
|
||||
spine.error = spine.skeletonFileName ? '配置未包含可复用的 Spine 原始资源' : ''
|
||||
spine.animations = []
|
||||
spine.bones = []
|
||||
spine.selectedAnimation = ''
|
||||
continue
|
||||
}
|
||||
registerSpineAsset(spine.id, parsed)
|
||||
spine.animations = parsed.animations
|
||||
spine.bones = parsed.bones
|
||||
if (!parsed.animations.some((animation) => animation.name === spine.selectedAnimation)) {
|
||||
spine.selectedAnimation = parsed.animations[0]?.name || ''
|
||||
}
|
||||
spine.status = 'ready'
|
||||
spine.error = ''
|
||||
}
|
||||
|
||||
store.syncSceneSequences()
|
||||
repairSelection(store, config.selection)
|
||||
store.timeline.frame = 0
|
||||
store.timeline.recorded = false
|
||||
store.timeline.playing = false
|
||||
for (const system of store.systems) {
|
||||
if (!store.timeline.animations.includes(system.animation)) system.animation = store.timeline.animations[0]
|
||||
}
|
||||
store.recalcTotalFrames()
|
||||
return config
|
||||
}
|
||||
|
||||
export async function loadEditorConfigText(store: EditorStore, text: string) {
|
||||
return applyEditorConfig(store, parseEditorConfig(text))
|
||||
}
|
||||
|
||||
export async function loadEditorConfigFile(store: EditorStore, file: File) {
|
||||
if (!/\.json$/i.test(file.name)) throw new Error('请选择 .json 配置文件')
|
||||
return loadEditorConfigText(store, await file.text())
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { reactive, watch, type WatchStopHandle } from 'vue'
|
||||
import type { useParticleStore, ParticleSystem, CollisionBody, ScenePath, SettingsState } from '../store/particleStore'
|
||||
import type { SpineSceneObject } from '../spine/spineTypes'
|
||||
import type { LoadedSpineAsset } from '../spine/spineAssetRegistry'
|
||||
import {
|
||||
registerSpineAsset,
|
||||
releaseLoadedSpineAsset,
|
||||
releaseSpineAsset,
|
||||
retainSpineAssetForHistory,
|
||||
} from '../spine/spineAssetRegistry'
|
||||
import { cloneEditorValue } from './editorClone'
|
||||
|
||||
type EditorStore = ReturnType<typeof useParticleStore>
|
||||
type UndoableSettings = Omit<SettingsState, 'undoLimit'>
|
||||
|
||||
interface EditorSnapshotData {
|
||||
systems: ParticleSystem[]
|
||||
colliders: CollisionBody[]
|
||||
paths: ScenePath[]
|
||||
spines: SpineSceneObject[]
|
||||
animations: string[]
|
||||
settings: UndoableSettings
|
||||
}
|
||||
|
||||
interface EditorSnapshot {
|
||||
data: EditorSnapshotData
|
||||
signature: string
|
||||
spineAssets: Map<number, LoadedSpineAsset>
|
||||
}
|
||||
|
||||
export const editorHistoryState = reactive({ undoCount: 0 })
|
||||
|
||||
let managedStore: EditorStore | null = null
|
||||
let currentSnapshot: EditorSnapshot | null = null
|
||||
let undoStack: EditorSnapshot[] = []
|
||||
let stopHistoryWatch: WatchStopHandle | null = null
|
||||
let commitTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let applyingSnapshot = false
|
||||
|
||||
function captureData(store: EditorStore): EditorSnapshotData {
|
||||
const systems = store.systems.map((system) => {
|
||||
const copy = cloneEditorValue(system) as unknown as ParticleSystem
|
||||
copy.frames = undefined
|
||||
return copy
|
||||
})
|
||||
const { undoLimit: _undoLimit, ...settings } = cloneEditorValue(store.settings)
|
||||
return {
|
||||
systems,
|
||||
colliders: cloneEditorValue(store.colliders) as unknown as CollisionBody[],
|
||||
paths: cloneEditorValue(store.paths) as unknown as ScenePath[],
|
||||
spines: cloneEditorValue(store.spines) as unknown as SpineSceneObject[],
|
||||
animations: [...store.timeline.animations],
|
||||
settings,
|
||||
}
|
||||
}
|
||||
|
||||
function dataSignature(data: EditorSnapshotData) {
|
||||
return JSON.stringify(data, (key, value) => {
|
||||
if (key === 'texture' || key === 'trailTexture') return null
|
||||
if (typeof value === 'string' && value.startsWith('data:') && value.length > 256) {
|
||||
return `${value.length}:${value.slice(0, 96)}:${value.slice(-48)}`
|
||||
}
|
||||
return value
|
||||
})
|
||||
}
|
||||
|
||||
function createSnapshot(store: EditorStore, data = captureData(store), signature = dataSignature(data)): EditorSnapshot {
|
||||
const spineAssets = new Map<number, LoadedSpineAsset>()
|
||||
for (const spine of data.spines) {
|
||||
const asset = retainSpineAssetForHistory(spine.id)
|
||||
if (asset) spineAssets.set(spine.id, asset)
|
||||
}
|
||||
return { data, signature, spineAssets }
|
||||
}
|
||||
|
||||
function releaseSnapshot(snapshot: EditorSnapshot | null) {
|
||||
if (!snapshot) return
|
||||
for (const asset of snapshot.spineAssets.values()) releaseLoadedSpineAsset(asset)
|
||||
snapshot.spineAssets.clear()
|
||||
}
|
||||
|
||||
function trimUndoStack(limit: number) {
|
||||
while (undoStack.length > limit) releaseSnapshot(undoStack.shift() || null)
|
||||
editorHistoryState.undoCount = undoStack.length
|
||||
}
|
||||
|
||||
function commitCurrentChange() {
|
||||
const store = managedStore
|
||||
if (!store || !currentSnapshot || applyingSnapshot) return
|
||||
const data = captureData(store)
|
||||
const signature = dataSignature(data)
|
||||
if (signature === currentSnapshot.signature) return
|
||||
undoStack.push(currentSnapshot)
|
||||
currentSnapshot = createSnapshot(store, data, signature)
|
||||
trimUndoStack(store.settings.undoLimit)
|
||||
}
|
||||
|
||||
function scheduleCommit() {
|
||||
if (applyingSnapshot) return
|
||||
if (commitTimer) clearTimeout(commitTimer)
|
||||
commitTimer = setTimeout(() => {
|
||||
commitTimer = null
|
||||
commitCurrentChange()
|
||||
}, 250)
|
||||
}
|
||||
|
||||
function sceneObjectExists(store: EditorStore) {
|
||||
const id = store.activeObjectId
|
||||
if (store.activeObjectType === 'particle') return store.systems.some((item) => item.id === id)
|
||||
if (store.activeObjectType === 'collision') return store.colliders.some((item) => item.id === id)
|
||||
if (store.activeObjectType === 'path') return store.paths.some((item) => item.id === id)
|
||||
return store.spines.some((item) => item.id === id)
|
||||
}
|
||||
|
||||
function repairSelection(store: EditorStore) {
|
||||
if (!sceneObjectExists(store)) {
|
||||
const particle = store.systems[0]
|
||||
const collider = store.colliders[0]
|
||||
const path = store.paths[0]
|
||||
const spine = store.spines[0]
|
||||
if (particle) {
|
||||
store.activeObjectType = 'particle'
|
||||
store.activeObjectId = particle.id
|
||||
} else if (collider) {
|
||||
store.activeObjectType = 'collision'
|
||||
store.activeObjectId = collider.id
|
||||
} else if (path) {
|
||||
store.activeObjectType = 'path'
|
||||
store.activeObjectId = path.id
|
||||
} else {
|
||||
store.activeObjectType = 'spine'
|
||||
store.activeObjectId = spine?.id || 0
|
||||
}
|
||||
}
|
||||
const activeParticle = store.activeObjectType === 'particle'
|
||||
? store.systems.find((item) => item.id === store.activeObjectId)
|
||||
: store.systems.find((item) => item.id === store.activeId)
|
||||
store.activeId = activeParticle?.id || store.systems[0]?.id || 0
|
||||
store.timeline.animation = activeParticle?.animation || store.systems[0]?.animation || store.timeline.animations[0] || 'animation'
|
||||
}
|
||||
|
||||
function restoreSnapshot(store: EditorStore, target: EditorSnapshot) {
|
||||
const previousSnapshot = currentSnapshot
|
||||
applyingSnapshot = true
|
||||
try {
|
||||
for (const spine of store.spines) releaseSpineAsset(spine.id)
|
||||
store.systems = cloneEditorValue(target.data.systems)
|
||||
store.colliders = cloneEditorValue(target.data.colliders)
|
||||
store.paths = cloneEditorValue(target.data.paths)
|
||||
store.spines = cloneEditorValue(target.data.spines)
|
||||
store.timeline.animations = [...target.data.animations]
|
||||
Object.assign(store.settings, cloneEditorValue(target.data.settings))
|
||||
for (const [id, asset] of target.spineAssets) registerSpineAsset(id, asset)
|
||||
for (const system of store.systems) system.frames = []
|
||||
store.timeline.recorded = false
|
||||
store.timeline.playing = false
|
||||
repairSelection(store)
|
||||
store.recalcTotalFrames()
|
||||
currentSnapshot = target
|
||||
} finally {
|
||||
applyingSnapshot = false
|
||||
releaseSnapshot(previousSnapshot)
|
||||
}
|
||||
}
|
||||
|
||||
export function installEditorHistory(store: EditorStore) {
|
||||
if (managedStore === store && stopHistoryWatch) return
|
||||
stopHistoryWatch?.()
|
||||
if (commitTimer) clearTimeout(commitTimer)
|
||||
releaseSnapshot(currentSnapshot)
|
||||
for (const snapshot of undoStack) releaseSnapshot(snapshot)
|
||||
undoStack = []
|
||||
managedStore = store
|
||||
currentSnapshot = createSnapshot(store)
|
||||
editorHistoryState.undoCount = 0
|
||||
stopHistoryWatch = watch(
|
||||
() => dataSignature(captureData(store)),
|
||||
scheduleCommit,
|
||||
{ flush: 'post' },
|
||||
)
|
||||
}
|
||||
|
||||
export function setUndoLimit(store: EditorStore, value: number) {
|
||||
const limit = Math.min(200, Math.max(1, Math.round(Number(value) || 20)))
|
||||
store.settings.undoLimit = limit
|
||||
trimUndoStack(limit)
|
||||
}
|
||||
|
||||
export function undoLastOperation() {
|
||||
const store = managedStore
|
||||
if (!store || !currentSnapshot) return false
|
||||
if (commitTimer) {
|
||||
clearTimeout(commitTimer)
|
||||
commitTimer = null
|
||||
}
|
||||
commitCurrentChange()
|
||||
const target = undoStack.pop()
|
||||
if (!target) return false
|
||||
restoreSnapshot(store, target)
|
||||
editorHistoryState.undoCount = undoStack.length
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { onMounted, onUnmounted, type Ref } from 'vue'
|
||||
|
||||
type CanvasTool = '' | 'translate' | 'rotate' | 'scale'
|
||||
|
||||
interface EditorShortcutOptions {
|
||||
canvasElement: Ref<HTMLElement | null>
|
||||
canvasFocused: Ref<boolean>
|
||||
undo: () => void
|
||||
save: () => void
|
||||
togglePlay: () => void
|
||||
toStart: () => void
|
||||
setTool: (tool: CanvasTool) => void
|
||||
}
|
||||
|
||||
function isTextEditor(target: EventTarget | null) {
|
||||
const element = target instanceof HTMLElement ? target : null
|
||||
return !!element?.closest('input, textarea, select, [contenteditable="true"]')
|
||||
}
|
||||
|
||||
/** 统一覆盖编辑器常用快捷键,并在输入控件中保留普通文字输入。 */
|
||||
export function useEditorShortcuts(options: EditorShortcutOptions) {
|
||||
function onPointerDown(event: PointerEvent) {
|
||||
const target = event.target instanceof Node ? event.target : null
|
||||
options.canvasFocused.value = !!target && !!options.canvasElement.value?.contains(target)
|
||||
}
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
const key = event.key.toLowerCase()
|
||||
if ((event.ctrlKey || event.metaKey) && !event.altKey && key === 's') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
options.save()
|
||||
return
|
||||
}
|
||||
if ((event.ctrlKey || event.metaKey) && !event.altKey && key === 'z') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
options.undo()
|
||||
return
|
||||
}
|
||||
if (event.ctrlKey || event.metaKey || event.altKey || isTextEditor(event.target)) return
|
||||
|
||||
if (key === 'd') {
|
||||
event.preventDefault()
|
||||
options.togglePlay()
|
||||
return
|
||||
}
|
||||
if (key === 's') {
|
||||
event.preventDefault()
|
||||
options.toStart()
|
||||
return
|
||||
}
|
||||
if (!options.canvasFocused.value) return
|
||||
|
||||
const tools: Record<string, CanvasTool> = { q: '', w: 'translate', e: 'rotate', r: 'scale' }
|
||||
if (!(key in tools)) return
|
||||
event.preventDefault()
|
||||
options.setTool(tools[key])
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('pointerdown', onPointerDown, true)
|
||||
window.addEventListener('keydown', onKeyDown, true)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('pointerdown', onPointerDown, true)
|
||||
window.removeEventListener('keydown', onKeyDown, true)
|
||||
})
|
||||
}
|
||||
@@ -108,6 +108,7 @@ import { useParticleStore } from '../store/particleStore'
|
||||
import { parseSpineBundle } from './spineAssetLoader'
|
||||
import { registerSpineAsset, releaseSpineAsset } from './spineAssetRegistry'
|
||||
import type { SpineSceneObject } from './spineTypes'
|
||||
import { embedSpineSource } from './spineSourceBundle'
|
||||
|
||||
const props = defineProps<{ spineObject: SpineSceneObject }>()
|
||||
const store = useParticleStore()
|
||||
@@ -123,6 +124,7 @@ const textureFiles = ref<File[]>([])
|
||||
|
||||
// 兼容热更新或旧场景数据;骨骼选择默认保持为空。
|
||||
if (props.spineObject.selectedBone == null) props.spineObject.selectedBone = ''
|
||||
if (props.spineObject.sourceBundle == null) props.spineObject.sourceBundle = null
|
||||
|
||||
const skeletonName = computed(() => skeletonFile.value?.name || props.spineObject.skeletonFileName)
|
||||
const atlasName = computed(() => atlasFile.value?.name || props.spineObject.atlasFileName)
|
||||
@@ -212,6 +214,8 @@ async function loadBundle() {
|
||||
props.spineObject.status = 'loading'
|
||||
props.spineObject.error = ''
|
||||
try {
|
||||
// 保持原有显示链路:先解析并一次性提交给运行时,不让配置序列化
|
||||
// 阻塞或介入 Spine 的首次创建。
|
||||
const parsed = await parseSpineBundle(skeleton, atlas, textureFiles.value)
|
||||
registerSpineAsset(props.spineObject.id, parsed)
|
||||
props.spineObject.skeletonFileName = skeleton.name
|
||||
@@ -227,6 +231,14 @@ async function loadBundle() {
|
||||
props.spineObject.status = 'ready'
|
||||
recalcTimeline()
|
||||
store.timeline.frame = 0
|
||||
// Data URL 只作为保存配置的副本,在 Spine 已显示后独立生成。
|
||||
// 序列化失败不应影响当前已加载的运行时资源。
|
||||
try {
|
||||
props.spineObject.sourceBundle = await embedSpineSource(skeleton, atlas, textureFiles.value)
|
||||
} catch (error) {
|
||||
props.spineObject.sourceBundle = null
|
||||
console.warn('Spine 配置资源嵌入失败', error)
|
||||
}
|
||||
} catch (error) {
|
||||
props.spineObject.status = 'error'
|
||||
props.spineObject.error = error instanceof Error ? error.message : String(error)
|
||||
@@ -245,6 +257,7 @@ function clearBundle() {
|
||||
props.spineObject.skeletonFileName = ''
|
||||
props.spineObject.atlasFileName = ''
|
||||
props.spineObject.textureFileNames = []
|
||||
props.spineObject.sourceBundle = null
|
||||
props.spineObject.animations = []
|
||||
props.spineObject.bones = []
|
||||
props.spineObject.selectedBone = ''
|
||||
|
||||
@@ -46,6 +46,8 @@ export class SpineRuntimeLayer {
|
||||
}
|
||||
const root = new Container()
|
||||
const spine = new Spine({ skeletonData: asset.skeletonData, autoUpdate: false, darkTint: false })
|
||||
if (asset.skeletonData.defaultSkin) spine.skeleton.setSkin(asset.skeletonData.defaultSkin)
|
||||
spine.skeleton.setSlotsToSetupPose()
|
||||
root.addChild(spine)
|
||||
this.container.addChild(root)
|
||||
entry = { root, spine, assetVersion: object.assetVersion, animation: '' }
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type SkeletonData,
|
||||
} from '@esotericsoftware/spine-core'
|
||||
import { SpineTexture } from '@esotericsoftware/spine-pixi-v7'
|
||||
import { Texture } from 'pixi.js'
|
||||
import { ALPHA_MODES, Texture } from 'pixi.js'
|
||||
import type { LoadedSpineAsset } from './spineAssetRegistry'
|
||||
import type { SpineAnimationInfo, SpineBoneInfo } from './spineTypes'
|
||||
|
||||
@@ -21,11 +21,7 @@ function basename(path: string) {
|
||||
|
||||
async function readTexture(file: File) {
|
||||
const url = URL.createObjectURL(file)
|
||||
try {
|
||||
return await Texture.fromURL(url)
|
||||
} finally {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
return await Texture.fromURL(url)
|
||||
}
|
||||
|
||||
function boneMetadata(skeletonData: SkeletonData): SpineBoneInfo[] {
|
||||
@@ -51,6 +47,9 @@ export async function parseSpineBundle(skeletonFile: File, atlasFile: File, text
|
||||
const file = fileMap.get(basename(page.name).toLowerCase())
|
||||
if (!file) throw new Error(`Atlas 引用的纹理未选择:${page.name}`)
|
||||
const texture = await readTexture(file)
|
||||
// 与 Spine 官方 PixiJS Atlas 加载器保持一致。Spine 导出的 Atlas
|
||||
// 常使用 pma:true;忽略该标记会让骨架数据正常但附件纹理无法正确合成。
|
||||
texture.baseTexture.alphaMode = page.pma ? ALPHA_MODES.PMA : ALPHA_MODES.UNPACK
|
||||
textures.push(texture)
|
||||
page.setTexture(SpineTexture.from(texture.baseTexture))
|
||||
}
|
||||
|
||||
@@ -7,7 +7,24 @@ export interface LoadedSpineAsset {
|
||||
}
|
||||
|
||||
const assets = new Map<number, LoadedSpineAsset>()
|
||||
const assetReferences = new Map<LoadedSpineAsset, number>()
|
||||
const historyReferences = new Map<LoadedSpineAsset, number>()
|
||||
|
||||
function isRegistered(asset: LoadedSpineAsset) {
|
||||
return [...assets.values()].some((registered) => registered === asset)
|
||||
}
|
||||
|
||||
function destroyIfUnused(asset: LoadedSpineAsset) {
|
||||
if (isRegistered(asset) || (historyReferences.get(asset) || 0) > 0) return
|
||||
historyReferences.delete(asset)
|
||||
for (const texture of asset.textures) texture.destroy(true)
|
||||
}
|
||||
|
||||
export function releaseLoadedSpineAsset(asset: LoadedSpineAsset) {
|
||||
const references = historyReferences.get(asset) || 0
|
||||
if (references > 1) historyReferences.set(asset, references - 1)
|
||||
else historyReferences.delete(asset)
|
||||
destroyIfUnused(asset)
|
||||
}
|
||||
|
||||
export function getSpineAsset(id: number) {
|
||||
return assets.get(id) || null
|
||||
@@ -16,7 +33,14 @@ export function getSpineAsset(id: number) {
|
||||
export function registerSpineAsset(id: number, asset: LoadedSpineAsset) {
|
||||
releaseSpineAsset(id)
|
||||
assets.set(id, asset)
|
||||
assetReferences.set(asset, (assetReferences.get(asset) || 0) + 1)
|
||||
}
|
||||
|
||||
/** 为撤回快照保留资源,直到该快照从历史队列中移除。 */
|
||||
export function retainSpineAssetForHistory(id: number) {
|
||||
const asset = assets.get(id)
|
||||
if (!asset) return null
|
||||
historyReferences.set(asset, (historyReferences.get(asset) || 0) + 1)
|
||||
return asset
|
||||
}
|
||||
|
||||
/** 让另一个场景对象复用同一套只读 Spine 解析资源。 */
|
||||
@@ -31,11 +55,5 @@ export function releaseSpineAsset(id: number) {
|
||||
const previous = assets.get(id)
|
||||
if (!previous) return
|
||||
assets.delete(id)
|
||||
const references = assetReferences.get(previous) || 1
|
||||
if (references > 1) {
|
||||
assetReferences.set(previous, references - 1)
|
||||
return
|
||||
}
|
||||
assetReferences.delete(previous)
|
||||
for (const texture of previous.textures) texture.destroy(true)
|
||||
destroyIfUnused(previous)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
export interface EmbeddedSpineFile {
|
||||
name: string
|
||||
dataUrl: string
|
||||
}
|
||||
|
||||
export interface SpineSourceBundle {
|
||||
skeleton: EmbeddedSpineFile
|
||||
atlas: EmbeddedSpineFile
|
||||
textures: EmbeddedSpineFile[]
|
||||
}
|
||||
|
||||
export function fileToDataUrl(file: File) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(String(reader.result || ''))
|
||||
reader.onerror = () => reject(reader.error)
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
export async function embedSpineSource(skeleton: File, atlas: File, textures: File[]): Promise<SpineSourceBundle> {
|
||||
return {
|
||||
skeleton: { name: skeleton.name, dataUrl: await fileToDataUrl(skeleton) },
|
||||
atlas: { name: atlas.name, dataUrl: await fileToDataUrl(atlas) },
|
||||
textures: await Promise.all(textures.map(async (file) => ({ name: file.name, dataUrl: await fileToDataUrl(file) }))),
|
||||
}
|
||||
}
|
||||
|
||||
async function embeddedFile(source: EmbeddedSpineFile) {
|
||||
const response = await fetch(source.dataUrl)
|
||||
if (!response.ok) throw new Error(`无法读取内嵌 Spine 文件:${source.name}`)
|
||||
return new File([await response.blob()], source.name)
|
||||
}
|
||||
|
||||
export async function restoreSpineSource(source: SpineSourceBundle) {
|
||||
return {
|
||||
skeleton: await embeddedFile(source.skeleton),
|
||||
atlas: await embeddedFile(source.atlas),
|
||||
textures: await Promise.all(source.textures.map(embeddedFile)),
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { SpineSourceBundle } from './spineSourceBundle'
|
||||
|
||||
export interface SpineAnimationInfo {
|
||||
name: string
|
||||
duration: number
|
||||
@@ -23,6 +25,8 @@ export interface SpineSceneObject {
|
||||
skeletonFileName: string
|
||||
atlasFileName: string
|
||||
textureFileNames: string[]
|
||||
/** 保存配置时使用的可移植原始 Spine 文件。 */
|
||||
sourceBundle: SpineSourceBundle | null
|
||||
selectedAnimation: string
|
||||
selectedBone: string
|
||||
animations: SpineAnimationInfo[]
|
||||
|
||||
+37
-15
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
||||
import { EmitterConfig, defaultConfig, ParticleState, type SceneCollider } from '../core/particleEmitter'
|
||||
import type { SpineSceneObject } from '../spine/spineTypes'
|
||||
import { releaseSpineAsset, retainSpineAsset } from '../spine/spineAssetRegistry'
|
||||
import { cloneEditorValue } from '../editor/editorClone'
|
||||
|
||||
export interface ParticleSystem {
|
||||
id: number
|
||||
@@ -62,6 +63,18 @@ export interface SettingsState {
|
||||
showAxisLabels: boolean
|
||||
/** 坐标轴数值颜色(#rrggbb) */
|
||||
axisColor: string
|
||||
/** 最大撤回操作数。 */
|
||||
undoLimit: number
|
||||
/** 背景图世界坐标与透明度。 */
|
||||
backgroundX: number
|
||||
backgroundY: number
|
||||
backgroundOpacity: number
|
||||
backgroundImageName: string
|
||||
backgroundImageData: string
|
||||
showEmitter: boolean
|
||||
showBones: boolean
|
||||
showSkinMesh: boolean
|
||||
showGrid: boolean
|
||||
}
|
||||
|
||||
export interface TimelineState {
|
||||
@@ -88,20 +101,6 @@ let colliderSeq = 1
|
||||
let pathSeq = 1
|
||||
let spineSeq = 1
|
||||
|
||||
/** 深拷贝编辑器配置,但保留 Texture 等运行时类实例的共享引用。 */
|
||||
function cloneEditorValue<T>(value: T): T {
|
||||
if (Array.isArray(value)) return value.map((item) => cloneEditorValue(item)) as T
|
||||
if (value && typeof value === 'object') {
|
||||
const prototype = Object.getPrototypeOf(value)
|
||||
if (prototype === Object.prototype || prototype === null) {
|
||||
const copy: Record<string, unknown> = {}
|
||||
for (const [key, item] of Object.entries(value)) copy[key] = cloneEditorValue(item)
|
||||
return copy as T
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function copyName(name: string, names: string[]) {
|
||||
const existing = new Set(names)
|
||||
const base = `${name} Copy`
|
||||
@@ -123,12 +122,34 @@ export const useParticleStore = defineStore('particle', {
|
||||
activeObjectId: 0,
|
||||
// 与 Spine 编辑器默认时间基准对齐:1 秒 = 30 帧。
|
||||
timeline: { playing: true, loop: true, frame: 0, totalFrames: 60, fps: 30, recorded: false, animation: 'animation', animations: ['animation'] } as TimelineState,
|
||||
settings: { tickEnabled: false, axisFontSize: 12, tickWidth: 1, showAxisLabels: true, axisColor: '#7a86ad' } as SettingsState,
|
||||
settings: {
|
||||
tickEnabled: false,
|
||||
axisFontSize: 12,
|
||||
tickWidth: 1,
|
||||
showAxisLabels: true,
|
||||
axisColor: '#7a86ad',
|
||||
undoLimit: 20,
|
||||
backgroundX: 0,
|
||||
backgroundY: 0,
|
||||
backgroundOpacity: 1,
|
||||
backgroundImageName: '',
|
||||
backgroundImageData: '',
|
||||
showEmitter: true,
|
||||
showBones: false,
|
||||
showSkinMesh: false,
|
||||
showGrid: true,
|
||||
} as SettingsState,
|
||||
}),
|
||||
getters: {
|
||||
activeSystemCount: (s) => s.systems.length,
|
||||
},
|
||||
actions: {
|
||||
syncSceneSequences() {
|
||||
seq = Math.max(seq, ...this.systems.map((item) => item.id + 1), 1)
|
||||
colliderSeq = Math.max(colliderSeq, ...this.colliders.map((item) => item.id + 1), 1)
|
||||
pathSeq = Math.max(pathSeq, ...this.paths.map((item) => item.id + 1), 1)
|
||||
spineSeq = Math.max(spineSeq, ...this.spines.map((item) => item.id + 1), 1)
|
||||
},
|
||||
addSystem(initial?: Partial<{ config: Partial<EmitterConfig> }>) {
|
||||
const cfg = defaultConfig()
|
||||
if (initial?.config) Object.assign(cfg, initial.config)
|
||||
@@ -283,6 +304,7 @@ export const useParticleStore = defineStore('particle', {
|
||||
skeletonFileName: '',
|
||||
atlasFileName: '',
|
||||
textureFileNames: [],
|
||||
sourceBundle: null,
|
||||
selectedAnimation: '',
|
||||
selectedBone: '',
|
||||
animations: [],
|
||||
|
||||
@@ -4,6 +4,17 @@
|
||||
<span>粒子系统 · 编辑器</span>
|
||||
</div>
|
||||
|
||||
<div class="config-toolbar">
|
||||
<button title="保存全部编辑器配置 (Ctrl+S)" @click="saveConfig">保存配置</button>
|
||||
<button title="加载本地编辑器配置" @click="configFileInput?.click()">加载配置</button>
|
||||
<select v-model="selectedPreset" aria-label="预设列表" :disabled="!presetOptions.length || presetLoading" @pointerdown="refreshPresets" @focus="refreshPresets" @change="loadSelectedPreset">
|
||||
<option value="">{{ presetOptions.length ? '选择预设' : '无预设文件' }}</option>
|
||||
<option v-for="preset in presetOptions" :key="preset.file" :value="preset.file">{{ preset.name }}</option>
|
||||
</select>
|
||||
<input ref="configFileInput" hidden type="file" accept=".json,application/json" @change="onConfigFileChange" />
|
||||
</div>
|
||||
<div v-if="configMessage" class="config-message" :class="{ error: configError }">{{ configMessage }}</div>
|
||||
|
||||
<!-- 1. 场景对象:创建入口始终可用,即使当前没有粒子系统。 -->
|
||||
<div class="group">
|
||||
<div class="group-head scene-head" @click="toggle('scene')">
|
||||
@@ -768,7 +779,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, markRaw, reactive, ref, watchEffect } from 'vue'
|
||||
import { computed, markRaw, onMounted, reactive, ref, watchEffect } from 'vue'
|
||||
import { Texture } from 'pixi.js'
|
||||
import { useParticleStore } from '../store/particleStore'
|
||||
import { defaultConfig, ensureEmitterConfig } from '../core/particleEmitter'
|
||||
@@ -778,11 +789,76 @@ import ParticleAttributeControl from './ParticleAttributeControl.vue'
|
||||
import ColorGradientEditor from './ColorGradientEditor.vue'
|
||||
import ImageResourceCard from './ImageResourceCard.vue'
|
||||
import SpineObjectPanel from '../spine/SpineObjectPanel.vue'
|
||||
import { downloadEditorConfig, loadEditorConfigFile, loadEditorConfigText } from '../editor/editorConfig'
|
||||
|
||||
const store = useParticleStore()
|
||||
const configFileInput = ref<HTMLInputElement | null>(null)
|
||||
const selectedPreset = ref('')
|
||||
const presetOptions = ref<Array<{ name: string; file: string }>>([])
|
||||
const presetLoading = ref(false)
|
||||
const configMessage = ref('')
|
||||
const configError = ref(false)
|
||||
// 默认场景始终有一个可编辑的粒子系统;热更新保留现有系统时不重复创建。
|
||||
if (!store.systems.length) store.addSystem()
|
||||
|
||||
function saveConfig() {
|
||||
downloadEditorConfig(store)
|
||||
configError.value = false
|
||||
configMessage.value = '配置已保存到本地'
|
||||
}
|
||||
|
||||
async function onConfigFileChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
configMessage.value = '正在加载配置…'
|
||||
configError.value = false
|
||||
try {
|
||||
await loadEditorConfigFile(store, file)
|
||||
configMessage.value = `已加载:${file.name}`
|
||||
} catch (error) {
|
||||
configError.value = true
|
||||
configMessage.value = error instanceof Error ? error.message : String(error)
|
||||
} finally {
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshPresets() {
|
||||
try {
|
||||
const response = await fetch(`/Preset/index.json?t=${Date.now()}`, { cache: 'no-store' })
|
||||
if (!response.ok) throw new Error('无法读取预设列表')
|
||||
const presets = await response.json()
|
||||
presetOptions.value = Array.isArray(presets)
|
||||
? presets.filter((item): item is { name: string; file: string } => !!item && typeof item.name === 'string' && typeof item.file === 'string')
|
||||
: []
|
||||
} catch {
|
||||
presetOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSelectedPreset() {
|
||||
const file = selectedPreset.value
|
||||
if (!file) return
|
||||
presetLoading.value = true
|
||||
configError.value = false
|
||||
configMessage.value = '正在加载预设…'
|
||||
try {
|
||||
const response = await fetch(`/Preset/${encodeURIComponent(file)}`, { cache: 'no-store' })
|
||||
if (!response.ok) throw new Error(`无法读取预设:${file}`)
|
||||
await loadEditorConfigText(store, await response.text())
|
||||
configMessage.value = `已加载预设:${file.replace(/\.json$/i, '')}`
|
||||
} catch (error) {
|
||||
configError.value = true
|
||||
configMessage.value = error instanceof Error ? error.message : String(error)
|
||||
} finally {
|
||||
presetLoading.value = false
|
||||
selectedPreset.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refreshPresets)
|
||||
|
||||
const activeIdx = computed(() => {
|
||||
const i = store.systems.findIndex((s) => s.id === store.activeId)
|
||||
return i >= 0 ? i : 0
|
||||
@@ -1434,6 +1510,17 @@ function toggleResourceLock(resourceId: number) {
|
||||
.panel::-webkit-scrollbar-thumb:hover { background: #3a3a55; }
|
||||
.panel { scrollbar-width: thin; scrollbar-color: #2e2e44 transparent; }
|
||||
.panel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; font-weight: 700; color: #ccd; }
|
||||
.config-toolbar { display: grid; grid-template-columns: auto auto minmax(0,1fr); gap: 6px; margin-bottom: 10px; }
|
||||
.config-toolbar button,.config-toolbar select {
|
||||
min-width: 0; height: 30px; box-sizing: border-box; border: 1px solid #3b4864; border-radius: 6px;
|
||||
background: #263149; color: #d6def0; font-size: 11px;
|
||||
}
|
||||
.config-toolbar button { padding: 0 9px; cursor: pointer; }
|
||||
.config-toolbar button:hover { border-color: #7169dc; background: #343d68; color: #fff; }
|
||||
.config-toolbar select { padding: 0 7px; overflow: hidden; text-overflow: ellipsis; }
|
||||
.config-toolbar select:disabled { color: #68738a; background: #1b2130; }
|
||||
.config-message { margin: -4px 0 9px; color: #83b9a2; font-size: 10px; }
|
||||
.config-message.error { color: #e8959e; }
|
||||
.empty, .panel-hint { color: #667; font-size: 12px; padding: 8px 0; }
|
||||
.group { border: 1px solid #2a2a3c; border-radius: 8px; margin-bottom: 10px; overflow: hidden; }
|
||||
.group-head {
|
||||
|
||||
+124
-57
@@ -1,17 +1,18 @@
|
||||
<template>
|
||||
<div class="stage-wrap">
|
||||
<div ref="holder" class="stage-holder" :class="{ grid: showGrid }" @wheel.prevent="onWheel" @pointerdown="onPanDown" @dblclick.prevent="onStageDoubleClick"></div>
|
||||
<div ref="holder" class="stage-holder" :class="{ grid: store.settings.showGrid }" tabindex="0" @wheel.prevent="onWheel" @pointerdown="onPanDown" @dblclick.prevent="onStageDoubleClick"></div>
|
||||
|
||||
<!-- 顶栏 -->
|
||||
<div class="topbar">
|
||||
<button class="settings-btn" @click="toggleSettings" title="系统设置">系统设置</button>
|
||||
<button class="undo-btn" :disabled="editorHistoryState.undoCount === 0" @click="undoLastOperation" title="撤回上一步 (Ctrl+Z)">↶</button>
|
||||
|
||||
<!-- 根变换工具:拖动(默认) / 位移 / 旋转 / 缩放 -->
|
||||
<div class="tool-group">
|
||||
<button class="tool-btn" :class="{ on: activeTool === '' }" @click="setTool('')" title="拖动画布">✋</button>
|
||||
<button class="tool-btn" :class="{ on: activeTool === 'translate' }" @click="setTool('translate')" title="位移">✥</button>
|
||||
<button class="tool-btn" :class="{ on: activeTool === 'rotate' }" @click="setTool('rotate')" title="旋转">⟳</button>
|
||||
<button class="tool-btn" :class="{ on: activeTool === 'scale' }" @click="setTool('scale')" title="缩放">⤢</button>
|
||||
<button class="tool-btn" :class="{ on: activeTool === '' }" @click="toggleTool('')" title="拖动画布 (Q)">✋</button>
|
||||
<button class="tool-btn" :class="{ on: activeTool === 'translate' }" @click="toggleTool('translate')" title="位移 (W)">✥</button>
|
||||
<button class="tool-btn" :class="{ on: activeTool === 'rotate' }" @click="toggleTool('rotate')" title="旋转 (E)">⟳</button>
|
||||
<button class="tool-btn" :class="{ on: activeTool === 'scale' }" @click="toggleTool('scale')" title="缩放 (R)">⤢</button>
|
||||
</div>
|
||||
|
||||
<!-- 工具按钮组(参考画布工具栏) -->
|
||||
@@ -19,7 +20,7 @@
|
||||
<button class="tool-btn" @click="zoomIn" title="放大">+</button>
|
||||
<button class="tool-btn" @click="zoomOut" title="缩小">-</button>
|
||||
<button class="tool-btn" @click="fitView" title="适配画布">⤢</button>
|
||||
<button class="tool-btn" :class="{ on: showGrid }" @click="toggleGrid" title="切换网格">▦</button>
|
||||
<button class="tool-btn" :class="{ on: store.settings.showGrid }" @click="toggleGrid" title="切换网格">▦</button>
|
||||
<button class="tool-btn" @click="resetView" title="重置视图">⟳</button>
|
||||
</div>
|
||||
|
||||
@@ -44,13 +45,13 @@
|
||||
/>
|
||||
<div class="background-file-name" :title="backgroundFileName">{{ backgroundFileName || '未上传图像' }}</div>
|
||||
<div class="background-position-row">
|
||||
<label><span>X</span><input v-model.number="backgroundX" type="number" step="1" aria-label="背景图 X 偏移" /></label>
|
||||
<label><span>Y</span><input v-model.number="backgroundY" type="number" step="1" aria-label="背景图 Y 偏移" /></label>
|
||||
<label><span>X</span><input v-model.number="store.settings.backgroundX" type="number" step="1" aria-label="背景图 X 偏移" /></label>
|
||||
<label><span>Y</span><input v-model.number="store.settings.backgroundY" type="number" step="1" aria-label="背景图 Y 偏移" /></label>
|
||||
</div>
|
||||
<label class="background-opacity-row">
|
||||
<span>透明度</span>
|
||||
<input v-model.number="backgroundOpacity" type="range" min="0" max="1" step="0.01" aria-label="背景图透明度" />
|
||||
<b>{{ Math.round(backgroundOpacity * 100) }}%</b>
|
||||
<input v-model.number="store.settings.backgroundOpacity" type="range" min="0" max="1" step="0.01" aria-label="背景图透明度" />
|
||||
<b>{{ Math.round(store.settings.backgroundOpacity * 100) }}%</b>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -62,10 +63,23 @@
|
||||
<!-- 系统设置面板(左上角弹出) -->
|
||||
<div class="settings-panel" v-if="showSettings">
|
||||
<div class="sp-head">系统设置 <button class="sp-close" @click="showSettings = false">✕</button></div>
|
||||
<div class="sp-section-title">操作历史</div>
|
||||
<label class="sp-row"><span>最大撤回步数</span>
|
||||
<input
|
||||
class="sp-num sp-undo-limit"
|
||||
type="number"
|
||||
min="1"
|
||||
max="200"
|
||||
v-model.number="store.settings.undoLimit"
|
||||
aria-label="最大撤回步数"
|
||||
@change="onUndoLimitChange"
|
||||
/>
|
||||
</label>
|
||||
<div class="sp-divider"></div>
|
||||
<div class="sp-section-title">画布辅助显示</div>
|
||||
<label class="sp-chk"><input type="checkbox" v-model="showEmitter" />发射点</label>
|
||||
<label class="sp-chk"><input type="checkbox" v-model="showBones" />骨骼连线</label>
|
||||
<label class="sp-chk"><input type="checkbox" v-model="showSkinMesh" />蒙皮网格</label>
|
||||
<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>
|
||||
<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>
|
||||
<template v-if="store.settings.tickEnabled">
|
||||
@@ -98,30 +112,49 @@ import { Application, Container, Graphics, Sprite, Text, Texture } from 'pixi.js
|
||||
import { ParticleEmitter, ensureEmitterConfig, type ParticleState } from '../core/particleEmitter'
|
||||
import { useParticleStore, type CollisionBody, type ScenePath } from '../store/particleStore'
|
||||
import { SpineRuntimeLayer } from '../spine/SpineRuntimeLayer'
|
||||
import { editorHistoryState, installEditorHistory, setUndoLimit, undoLastOperation } from '../editor/editorHistory'
|
||||
import { useEditorShortcuts } from '../editor/useEditorShortcuts'
|
||||
import { fileToDataUrl } from '../spine/spineSourceBundle'
|
||||
import { downloadEditorConfig } from '../editor/editorConfig'
|
||||
|
||||
const store = useParticleStore()
|
||||
const holder = ref<HTMLElement | null>(null)
|
||||
const showEmitter = ref(true)
|
||||
const showBones = ref(false)
|
||||
const showSkinMesh = ref(false)
|
||||
const info = ref<{ systems: number; particles: number } | null>(null)
|
||||
const showGrid = ref(true)
|
||||
const showSettings = ref(false)
|
||||
const showBackgroundMenu = ref(false)
|
||||
const backgroundFileInput = ref<HTMLInputElement | null>(null)
|
||||
const backgroundFileName = ref('')
|
||||
const backgroundX = ref(0)
|
||||
const backgroundY = ref(0)
|
||||
const backgroundOpacity = ref(1)
|
||||
const backgroundFileName = computed(() => store.settings.backgroundImageName)
|
||||
// 根变换 gizmo 工具:'translate' | 'rotate' | 'scale' | ''(无)
|
||||
const activeTool = ref<'' | 'translate' | 'rotate' | 'scale'>('')
|
||||
function setTool(t: '' | 'translate' | 'rotate' | 'scale') { activeTool.value = activeTool.value === t ? '' : t }
|
||||
const canvasFocused = ref(false)
|
||||
function toggleTool(t: '' | 'translate' | 'rotate' | 'scale') { activeTool.value = activeTool.value === t ? '' : t }
|
||||
|
||||
if (!(store.settings.undoLimit > 0)) store.settings.undoLimit = 20
|
||||
if (!Number.isFinite(store.settings.backgroundX)) store.settings.backgroundX = 0
|
||||
if (!Number.isFinite(store.settings.backgroundY)) store.settings.backgroundY = 0
|
||||
if (!Number.isFinite(store.settings.backgroundOpacity)) store.settings.backgroundOpacity = 1
|
||||
if (store.settings.backgroundImageName == null) store.settings.backgroundImageName = ''
|
||||
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.showSkinMesh == null) store.settings.showSkinMesh = false
|
||||
if (store.settings.showGrid == null) store.settings.showGrid = true
|
||||
|
||||
useEditorShortcuts({
|
||||
canvasElement: holder,
|
||||
canvasFocused,
|
||||
undo: undoLastOperation,
|
||||
save: () => downloadEditorConfig(store),
|
||||
togglePlay: () => store.togglePlay(),
|
||||
toStart: () => store.toStart(),
|
||||
setTool: (tool) => { activeTool.value = tool },
|
||||
})
|
||||
|
||||
let app: Application | null = null
|
||||
let world: Container | null = null
|
||||
let backgroundSprite: Sprite | null = null
|
||||
let backgroundTexture: Texture | null = null
|
||||
let backgroundObjectUrl = ''
|
||||
let backgroundLoadVersion = 0
|
||||
let originGfx: Graphics | null = null
|
||||
let boneFollowTargetGfx: Graphics | null = null
|
||||
let boneGfx: Graphics | null = null
|
||||
@@ -143,6 +176,7 @@ let dotTex: Texture<any> | null = null
|
||||
let trailTex: Texture<any> | null = null
|
||||
|
||||
const emitterMap = new Map<number, ParticleEmitter>()
|
||||
const emitterConfigMap = new Map<number, object>()
|
||||
let previewColliders: CollisionBody[] = []
|
||||
const emitterPreviewTransform = new Map<number, { x: number; y: number; rotation: number }>()
|
||||
const selectedPathNodes = new Set<number>()
|
||||
@@ -203,45 +237,59 @@ function toggleSettings() {
|
||||
if (showSettings.value) showBackgroundMenu.value = false
|
||||
}
|
||||
|
||||
function onUndoLimitChange(event: Event) {
|
||||
setUndoLimit(store, Number((event.target as HTMLInputElement).value))
|
||||
}
|
||||
|
||||
async function onBackgroundFileChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file || !backgroundSprite) return
|
||||
const nextUrl = URL.createObjectURL(file)
|
||||
if (!file) return
|
||||
try {
|
||||
const nextTexture = await Texture.fromURL(nextUrl)
|
||||
const previousTexture = backgroundTexture
|
||||
const previousUrl = backgroundObjectUrl
|
||||
backgroundTexture = nextTexture
|
||||
backgroundObjectUrl = nextUrl
|
||||
backgroundSprite.texture = nextTexture
|
||||
backgroundSprite.visible = true
|
||||
backgroundFileName.value = file.name
|
||||
if (previousTexture) previousTexture.destroy(true)
|
||||
if (previousUrl) URL.revokeObjectURL(previousUrl)
|
||||
store.settings.backgroundImageData = await fileToDataUrl(file)
|
||||
store.settings.backgroundImageName = file.name
|
||||
} catch (error) {
|
||||
URL.revokeObjectURL(nextUrl)
|
||||
console.error('背景图加载失败', error)
|
||||
} finally {
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function clearBackground(resetControls = true) {
|
||||
if (backgroundSprite) {
|
||||
backgroundSprite.texture = Texture.EMPTY
|
||||
backgroundSprite.visible = false
|
||||
async function syncBackgroundTexture(dataUrl: string) {
|
||||
const version = ++backgroundLoadVersion
|
||||
if (!dataUrl) {
|
||||
if (backgroundSprite) {
|
||||
backgroundSprite.texture = Texture.EMPTY
|
||||
backgroundSprite.visible = false
|
||||
}
|
||||
if (backgroundTexture) backgroundTexture.destroy(true)
|
||||
backgroundTexture = null
|
||||
return
|
||||
}
|
||||
if (backgroundTexture) backgroundTexture.destroy(true)
|
||||
if (backgroundObjectUrl) URL.revokeObjectURL(backgroundObjectUrl)
|
||||
backgroundTexture = null
|
||||
backgroundObjectUrl = ''
|
||||
backgroundFileName.value = ''
|
||||
try {
|
||||
const texture = await Texture.fromURL(dataUrl)
|
||||
if (version !== backgroundLoadVersion || !backgroundSprite) {
|
||||
texture.destroy(true)
|
||||
return
|
||||
}
|
||||
const previous = backgroundTexture
|
||||
backgroundTexture = texture
|
||||
backgroundSprite.texture = texture
|
||||
backgroundSprite.visible = true
|
||||
if (previous) previous.destroy(true)
|
||||
} catch (error) {
|
||||
console.error('背景图恢复失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
function clearBackground(resetControls = true) {
|
||||
store.settings.backgroundImageData = ''
|
||||
store.settings.backgroundImageName = ''
|
||||
if (backgroundFileInput.value) backgroundFileInput.value.value = ''
|
||||
if (resetControls) {
|
||||
backgroundX.value = 0
|
||||
backgroundY.value = 0
|
||||
backgroundOpacity.value = 1
|
||||
store.settings.backgroundX = 0
|
||||
store.settings.backgroundY = 0
|
||||
store.settings.backgroundOpacity = 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -737,12 +785,18 @@ function viewScaleBy(factor: number) {
|
||||
}
|
||||
function fitView() { store.editor.viewScale = 1.0; store.editor.viewX = 0; store.editor.viewY = 0 }
|
||||
function resetView() { store.editor.viewScale = 1.0; store.editor.viewX = 0; store.editor.viewY = 0 }
|
||||
function toggleGrid() { showGrid.value = !showGrid.value }
|
||||
function toggleGrid() { store.settings.showGrid = !store.settings.showGrid }
|
||||
|
||||
function syncEmitters() {
|
||||
if (!world) return
|
||||
for (const [id, em] of emitterMap) {
|
||||
if (!store.systems.some((s) => s.id === id)) { world.removeChild(em); em.destroy({ children: true }); emitterMap.delete(id) }
|
||||
const system = store.systems.find((item) => item.id === id)
|
||||
if (!system || emitterConfigMap.get(id) !== system.config) {
|
||||
world.removeChild(em)
|
||||
em.destroy({ children: true })
|
||||
emitterMap.delete(id)
|
||||
emitterConfigMap.delete(id)
|
||||
}
|
||||
}
|
||||
for (const sys of store.systems) {
|
||||
// 新增系统若还没贴图(dotTex 已加载后),补上默认点纹理,否则无法渲染
|
||||
@@ -764,6 +818,7 @@ function syncEmitters() {
|
||||
em.setEmitterPos(() => ({ x: sys.config.centerX, y: sys.config.centerY }))
|
||||
world.addChild(em)
|
||||
emitterMap.set(sys.id, em)
|
||||
emitterConfigMap.set(sys.id, sys.config)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -771,7 +826,7 @@ function drawOrigin() {
|
||||
if (!originGfx) return
|
||||
const g = originGfx
|
||||
g.clear()
|
||||
if (!showEmitter.value) return
|
||||
if (!store.settings.showEmitter) return
|
||||
// 每个系统对应一个发射点(坐标点);没有系统时不画
|
||||
if (!store.systems.length) return
|
||||
for (const sys of store.systems) {
|
||||
@@ -836,7 +891,7 @@ function drawShapeRange() {
|
||||
if (!shapeGfx) return
|
||||
const g = shapeGfx
|
||||
g.clear()
|
||||
if (!showEmitter.value) return
|
||||
if (!store.settings.showEmitter) return
|
||||
const sys = store.systems.find((s) => s.id === store.activeId)
|
||||
if (!sys || sys.visible === false) return
|
||||
const c = sys.config
|
||||
@@ -1022,7 +1077,7 @@ function drawBones(bones: BonePreview[]) {
|
||||
if (!boneGfx) return
|
||||
const g = boneGfx
|
||||
g.clear()
|
||||
if (!showBones.value) return
|
||||
if (!store.settings.showBones) return
|
||||
const cx = 0, cy = 0
|
||||
// root → 每颗粒子骨骼 的连线
|
||||
for (const b of bones) {
|
||||
@@ -1237,8 +1292,8 @@ function loop() {
|
||||
world.scale.set(store.editor.viewScale)
|
||||
world.position.set(app.screen.width / 2 + store.editor.viewX, app.screen.height * 0.5 + store.editor.viewY)
|
||||
if (backgroundSprite) {
|
||||
backgroundSprite.position.set(Number(backgroundX.value) || 0, -(Number(backgroundY.value) || 0))
|
||||
backgroundSprite.alpha = Math.min(1, Math.max(0, Number(backgroundOpacity.value) || 0))
|
||||
backgroundSprite.position.set(Number(store.settings.backgroundX) || 0, -(Number(store.settings.backgroundY) || 0))
|
||||
backgroundSprite.alpha = Math.min(1, Math.max(0, Number(store.settings.backgroundOpacity) || 0))
|
||||
}
|
||||
|
||||
drawAxis()
|
||||
@@ -1411,7 +1466,7 @@ function loop() {
|
||||
}
|
||||
timelineDisplayFrame = displayFrame
|
||||
spineRuntime?.sync(store.spines, displayFrame, fps)
|
||||
for (const [, emitter] of emitterMap) emitter.drawTrailMeshDebug(showSkinMesh.value)
|
||||
for (const [, emitter] of emitterMap) emitter.drawTrailMeshDebug(store.settings.showSkinMesh)
|
||||
drawCollisionBodies()
|
||||
drawPaths()
|
||||
drawBoneFollowTargets()
|
||||
@@ -1430,6 +1485,7 @@ onMounted(async () => {
|
||||
app = new Application({ width: el.clientWidth || 800, height: el.clientHeight || 600, backgroundAlpha: 0, antialias: true })
|
||||
el.appendChild(app.view as unknown as HTMLCanvasElement)
|
||||
createLayers()
|
||||
watch(() => store.settings.backgroundImageData, syncBackgroundTexture, { immediate: true })
|
||||
await loadTexture()
|
||||
const tex = dotTex as Texture<any>
|
||||
for (const sys of store.systems) {
|
||||
@@ -1474,13 +1530,16 @@ onMounted(async () => {
|
||||
store.timeline.loop = true
|
||||
},
|
||||
)
|
||||
installEditorHistory(store)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
cancelAnimationFrame(raf)
|
||||
spineRuntime?.destroy()
|
||||
spineRuntime = null
|
||||
clearBackground(false)
|
||||
backgroundLoadVersion++
|
||||
if (backgroundTexture) backgroundTexture.destroy(true)
|
||||
backgroundTexture = null
|
||||
backgroundSprite = null
|
||||
if (app) { app.destroy(true, { children: true }); app = null }
|
||||
})
|
||||
@@ -1492,6 +1551,7 @@ onUnmounted(() => {
|
||||
.stage-holder {
|
||||
width: 100%; height: 100%; cursor: grab; touch-action: none;
|
||||
background-color: #0e1219;
|
||||
outline: none;
|
||||
}
|
||||
.stage-holder.grid {
|
||||
background: conic-gradient(#141a26 90deg, transparent 90deg 180deg, #141a26 180deg 270deg, transparent 270deg);
|
||||
@@ -1557,6 +1617,12 @@ b { color: #ffcc33; }
|
||||
background: #202a3b; color: #cdf; font-size: 12px; font-weight: 700; cursor: pointer;
|
||||
}
|
||||
.settings-btn:hover { border-color: #7774ff; background: #2b3a5c; color: #fff; }
|
||||
.undo-btn {
|
||||
width: 30px; height: 28px; flex: 0 0 30px; border: 1px solid #3a4963; border-radius: 6px;
|
||||
background: #202a3b; color: #cdd8ed; font-size: 18px; line-height: 1; cursor: pointer;
|
||||
}
|
||||
.undo-btn:hover:not(:disabled) { border-color: #7774ff; background: #2b3a5c; color: #fff; }
|
||||
.undo-btn:disabled { opacity: .38; cursor: not-allowed; }
|
||||
.settings-panel {
|
||||
position: absolute; top: 52px; left: 10px; width: 240px; z-index: 10;
|
||||
background: rgba(20,20,32,0.97); border: 1px solid #2a2a3c; border-radius: 10px; padding: 10px 12px;
|
||||
@@ -1570,6 +1636,7 @@ b { color: #ffcc33; }
|
||||
.sp-row > span:first-child { width: 76px; flex-shrink: 0; color: #8a93bb; }
|
||||
.sp-row input[type=range] { flex: 1; background: #1a1a28; accent-color: #3a5a8c; }
|
||||
.sp-num { width: 52px; background: #1a1a28; border: 1px solid #2e2e44; border-radius: 5px; color: #dde; font-size: 12px; padding: 2px 4px; }
|
||||
.sp-undo-limit { margin-left: auto; width: 64px; height: 26px; box-sizing: border-box; }
|
||||
.sp-colr { width: 26px; height: 22px; border: none; background: none; cursor: pointer; }
|
||||
.sp-hex { font-size: 11px; color: #889; font-variant-numeric: tabular-nums; }
|
||||
.sp-chk { display: flex; align-items: center; gap: 6px; margin: 8px 0; color: #aab; }
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
<div class="timeline">
|
||||
<!-- 控制按钮 -->
|
||||
<div class="tl-controls">
|
||||
<button class="tl-btn" :class="{ on: store.timeline.playing }" @click="store.togglePlay()" :title="store.timeline.playing ? '暂停' : '播放'">
|
||||
<button class="tl-btn" :class="{ on: store.timeline.playing }" @click="store.togglePlay()" :title="store.timeline.playing ? '暂停 (D)' : '播放 (D)'">
|
||||
{{ store.timeline.playing ? '❚❚' : '▶' }}
|
||||
</button>
|
||||
<button class="tl-btn" @click="store.toStart()" title="回到第一帧">⏮</button>
|
||||
<button class="tl-btn" @click="store.toStart()" title="回到第一帧 (S)">⏮</button>
|
||||
<button class="tl-btn" @click="store.previousFrame()" title="前一帧">◀</button>
|
||||
<button class="tl-btn" @click="store.nextFrame()" title="后一帧">▶</button>
|
||||
<button class="tl-btn" @click="store.toEnd()" title="移动到最后一帧">⏭</button>
|
||||
|
||||
+35
-1
@@ -1,8 +1,42 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import type { Plugin } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { mkdirSync, readdirSync, writeFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
function presetManifestPlugin(): Plugin {
|
||||
let presetDirectory = ''
|
||||
const updateManifest = () => {
|
||||
if (!presetDirectory) return
|
||||
mkdirSync(presetDirectory, { recursive: true })
|
||||
const presets = readdirSync(presetDirectory)
|
||||
.filter((file) => file.toLowerCase().endsWith('.json') && file !== 'index.json')
|
||||
.sort((a, b) => a.localeCompare(b, 'zh-CN'))
|
||||
.map((file) => ({ name: file.replace(/\.json$/i, ''), file }))
|
||||
writeFileSync(resolve(presetDirectory, 'index.json'), `${JSON.stringify(presets, null, 2)}\n`)
|
||||
}
|
||||
return {
|
||||
name: 'spine-particle-preset-manifest',
|
||||
configResolved(config) {
|
||||
presetDirectory = resolve(config.root, 'public/Preset')
|
||||
updateManifest()
|
||||
},
|
||||
buildStart() {
|
||||
updateManifest()
|
||||
},
|
||||
configureServer(server) {
|
||||
server.watcher.add(presetDirectory)
|
||||
const refresh = (file: string) => {
|
||||
if (file.startsWith(presetDirectory) && file.toLowerCase().endsWith('.json')) updateManifest()
|
||||
}
|
||||
server.watcher.on('add', refresh)
|
||||
server.watcher.on('unlink', refresh)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
plugins: [vue(), presetManifestPlugin()],
|
||||
server: {
|
||||
port: 5173,
|
||||
host: true,
|
||||
|
||||
Reference in New Issue
Block a user