开发中途存档

This commit is contained in:
tianmo
2026-08-29 22:59:38 +08:00
parent a2de23a789
commit a80575ef5e
19 changed files with 1998 additions and 91 deletions
+14
View File
@@ -0,0 +1,14 @@
[
{
"name": "空场景",
"file": "空场景.json"
},
{
"name": "粒子+碰撞+路径",
"file": "粒子+碰撞+路径.json"
},
{
"name": "默认粒子",
"file": "默认粒子.json"
}
]
+42
View File
@@ -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": []
}
}
+563
View File
@@ -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": []
}
}
+473
View File
@@ -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": []
}
}
+16
View File
@@ -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
}
+241
View File
@@ -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())
}
+202
View File
@@ -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
}
+69
View File
@@ -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)
})
}
+13
View File
@@ -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 = ''
+2
View File
@@ -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: '' }
+5 -6
View File
@@ -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))
}
+27 -9
View File
@@ -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)
}
+41
View File
@@ -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)),
}
}
+4
View File
@@ -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
View File
@@ -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: [],
+88 -1
View File
@@ -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
View File
@@ -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 -2
View File
@@ -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
View File
@@ -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,