diff --git a/public/Preset/index.json b/public/Preset/index.json new file mode 100644 index 0000000..d343dac --- /dev/null +++ b/public/Preset/index.json @@ -0,0 +1,14 @@ +[ + { + "name": "空场景", + "file": "空场景.json" + }, + { + "name": "粒子+碰撞+路径", + "file": "粒子+碰撞+路径.json" + }, + { + "name": "默认粒子", + "file": "默认粒子.json" + } +] diff --git a/public/Preset/空场景.json b/public/Preset/空场景.json new file mode 100644 index 0000000..3ef0329 --- /dev/null +++ b/public/Preset/空场景.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": [] + } +} diff --git a/public/Preset/粒子+碰撞+路径.json b/public/Preset/粒子+碰撞+路径.json new file mode 100644 index 0000000..471c6e1 --- /dev/null +++ b/public/Preset/粒子+碰撞+路径.json @@ -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": [] + } +} \ No newline at end of file diff --git a/public/Preset/默认粒子.json b/public/Preset/默认粒子.json new file mode 100644 index 0000000..1bea28a --- /dev/null +++ b/public/Preset/默认粒子.json @@ -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": [] + } +} \ No newline at end of file diff --git a/src/editor/editorClone.ts b/src/editor/editorClone.ts new file mode 100644 index 0000000..86d6817 --- /dev/null +++ b/src/editor/editorClone.ts @@ -0,0 +1,16 @@ +/** + * 深拷贝编辑器中的普通配置对象和数组。 + * Pixi Texture 等运行时类实例作为只读资源句柄保留共享引用。 + */ +export function cloneEditorValue(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 = {} + for (const [key, item] of Object.entries(value)) copy[key] = cloneEditorValue(item) + return copy as T + } + } + return value +} diff --git a/src/editor/editorConfig.ts b/src/editor/editorConfig.ts new file mode 100644 index 0000000..e317f79 --- /dev/null +++ b/src/editor/editorConfig.ts @@ -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 + +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(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 + 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>() + 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() + 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()) +} diff --git a/src/editor/editorHistory.ts b/src/editor/editorHistory.ts new file mode 100644 index 0000000..cf7a6c5 --- /dev/null +++ b/src/editor/editorHistory.ts @@ -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 +type UndoableSettings = Omit + +interface EditorSnapshotData { + systems: ParticleSystem[] + colliders: CollisionBody[] + paths: ScenePath[] + spines: SpineSceneObject[] + animations: string[] + settings: UndoableSettings +} + +interface EditorSnapshot { + data: EditorSnapshotData + signature: string + spineAssets: Map +} + +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 | 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() + 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 +} diff --git a/src/editor/useEditorShortcuts.ts b/src/editor/useEditorShortcuts.ts new file mode 100644 index 0000000..cb220ff --- /dev/null +++ b/src/editor/useEditorShortcuts.ts @@ -0,0 +1,69 @@ +import { onMounted, onUnmounted, type Ref } from 'vue' + +type CanvasTool = '' | 'translate' | 'rotate' | 'scale' + +interface EditorShortcutOptions { + canvasElement: Ref + canvasFocused: Ref + 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 = { 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) + }) +} diff --git a/src/spine/SpineObjectPanel.vue b/src/spine/SpineObjectPanel.vue index 33b64fa..970a09f 100644 --- a/src/spine/SpineObjectPanel.vue +++ b/src/spine/SpineObjectPanel.vue @@ -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([]) // 兼容热更新或旧场景数据;骨骼选择默认保持为空。 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 = '' diff --git a/src/spine/SpineRuntimeLayer.ts b/src/spine/SpineRuntimeLayer.ts index 1303a79..9e57210 100644 --- a/src/spine/SpineRuntimeLayer.ts +++ b/src/spine/SpineRuntimeLayer.ts @@ -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: '' } diff --git a/src/spine/spineAssetLoader.ts b/src/spine/spineAssetLoader.ts index 3cbeb7b..cbdd603 100644 --- a/src/spine/spineAssetLoader.ts +++ b/src/spine/spineAssetLoader.ts @@ -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)) } diff --git a/src/spine/spineAssetRegistry.ts b/src/spine/spineAssetRegistry.ts index 9a8b9c1..b994d85 100644 --- a/src/spine/spineAssetRegistry.ts +++ b/src/spine/spineAssetRegistry.ts @@ -7,7 +7,24 @@ export interface LoadedSpineAsset { } const assets = new Map() -const assetReferences = new Map() +const historyReferences = new Map() + +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) } diff --git a/src/spine/spineSourceBundle.ts b/src/spine/spineSourceBundle.ts new file mode 100644 index 0000000..dc8920c --- /dev/null +++ b/src/spine/spineSourceBundle.ts @@ -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((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 { + 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)), + } +} diff --git a/src/spine/spineTypes.ts b/src/spine/spineTypes.ts index 63a0958..709d556 100644 --- a/src/spine/spineTypes.ts +++ b/src/spine/spineTypes.ts @@ -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[] diff --git a/src/store/particleStore.ts b/src/store/particleStore.ts index 83ea792..c5752ff 100644 --- a/src/store/particleStore.ts +++ b/src/store/particleStore.ts @@ -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(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 = {} - 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 }>) { 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: [], diff --git a/src/views/ParticlePanel.vue b/src/views/ParticlePanel.vue index 8da1623..09ac278 100644 --- a/src/views/ParticlePanel.vue +++ b/src/views/ParticlePanel.vue @@ -4,6 +4,17 @@ 粒子系统 · 编辑器 +
+ + + + +
+
{{ configMessage }}
+
@@ -768,7 +779,7 @@