修改标题,独立系统设置,可缓存

This commit is contained in:
tianmo
2026-09-01 18:01:35 +08:00
parent 1e49d2b99d
commit 96ee180564
18 changed files with 283 additions and 3013 deletions
-4
View File
@@ -2,9 +2,5 @@
{
"name": "粒子-序列帧-拖尾-碰撞-路径-spine",
"file": "粒子-序列帧-拖尾-碰撞-路径-spine.json"
},
{
"name": "粒子序列帧拖尾",
"file": "粒子序列帧拖尾.json"
}
]
@@ -1,29 +1,18 @@
{
"format": "SpineParticle.EditorConfig",
"version": 1,
"savedAt": "2026-09-01T09:14:16.879Z",
"savedAt": "2026-09-01T09:56:02.078Z",
"editor": {
"viewScale": 0.3606100249815796,
"viewX": -86.72420252825225,
"viewY": 43.3444575964476
"viewScale": 0.25667509294538904,
"viewX": -108.03375430502649,
"viewY": 58.10811303956338
},
"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,
"bonePointDrawSize": 1,
"boneAxisDrawSize": 2,
"showSkinMesh": false,
"showGrid": true,
"exportSettings": {
"imagesPath": "./images/",
@@ -2387,7 +2376,7 @@
"depth": 7
}
],
"assetVersion": 3,
"assetVersion": 4,
"status": "ready",
"error": ""
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -3,13 +3,13 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spine Particle · 阶段1 Spike</title>
<title>Spine粒子编辑器</title>
<style>
html, body { margin: 0; padding: 0; background: #111; color: #eee; font-family: system-ui, -apple-system, sans-serif; }
#app { display: flex; flex-direction: column; height: 100vh; }
</style>
<script type="module" crossorigin src="/assets/index-Dw7eU6re.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BPQ62DCC.css">
<script type="module" crossorigin src="/assets/index-DujVfZi9.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-nUMfzhFe.css">
</head>
<body>
<div id="app"></div>
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spine Particle · 阶段1 Spike</title>
<title>Spine粒子编辑器</title>
<style>
html, body { margin: 0; padding: 0; background: #111; color: #eee; font-family: system-ui, -apple-system, sans-serif; }
#app { display: flex; flex-direction: column; height: 100vh; }
-4
View File
@@ -2,9 +2,5 @@
{
"name": "粒子-序列帧-拖尾-碰撞-路径-spine",
"file": "粒子-序列帧-拖尾-碰撞-路径-spine.json"
},
{
"name": "粒子序列帧拖尾",
"file": "粒子序列帧拖尾.json"
}
]
@@ -1,29 +1,18 @@
{
"format": "SpineParticle.EditorConfig",
"version": 1,
"savedAt": "2026-09-01T09:14:16.879Z",
"savedAt": "2026-09-01T09:56:02.078Z",
"editor": {
"viewScale": 0.3606100249815796,
"viewX": -86.72420252825225,
"viewY": 43.3444575964476
"viewScale": 0.25667509294538904,
"viewX": -108.03375430502649,
"viewY": 58.10811303956338
},
"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,
"bonePointDrawSize": 1,
"boneAxisDrawSize": 2,
"showSkinMesh": false,
"showGrid": true,
"exportSettings": {
"imagesPath": "./images/",
@@ -2387,7 +2376,7 @@
"depth": 7
}
],
"assetVersion": 3,
"assetVersion": 4,
"status": "ready",
"error": ""
}
File diff suppressed because one or more lines are too long
+16 -6
View File
@@ -1,7 +1,7 @@
<template>
<div class="layout">
<!-- 左上:参数面板(宽度可拖) -->
<div class="left" :style="{ width: panelW + 'px' }">
<div class="layout" :class="{ 'panel-right': store.settings.parameterPanelOnRight }">
<!-- 参数面板可在系统设置中切换左右位置宽度可拖 -->
<div class="parameter-panel" :style="{ width: panelW + 'px' }">
<ParticlePanel />
</div>
<div class="resizer-col" @pointerdown="onPanelDrag"></div>
@@ -19,18 +19,25 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useParticleStore } from './store/particleStore'
import { installSystemSettingsCache } from './editor/systemSettings'
import Stage from './views/Stage.vue'
import ParticlePanel from './views/ParticlePanel.vue'
import Timeline from './views/Timeline.vue'
const store = useParticleStore()
installSystemSettingsCache(store)
const panelW = ref(360)
const timelineH = ref(180)
// 面板宽度拖拽
// 参数面板宽度拖拽:面板在右侧时,从视口右边缘反向计算宽度。
function onPanelDrag(e: PointerEvent) {
e.preventDefault()
const move = (ev: PointerEvent) => {
const w = Math.max(240, Math.min(ev.clientX, window.innerWidth - 200))
const pointerWidth = store.settings.parameterPanelOnRight
? window.innerWidth - ev.clientX
: ev.clientX
const w = Math.max(240, Math.min(pointerWidth, window.innerWidth - 200))
panelW.value = w
}
const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up) }
@@ -55,10 +62,13 @@ function onTimelineDrag(e: PointerEvent) {
<style scoped>
.layout { display: flex; width: 100%; height: 100%; min-height: 0; overflow: clip; }
.left { flex-shrink: 0; height: 100%; min-height: 0; overflow: clip; }
.parameter-panel { flex-shrink: 0; height: 100%; min-height: 0; overflow: clip; }
.resizer-col { width: 5px; cursor: col-resize; background: #1c1c2a; border-left: 1px solid #2a2a3c; border-right: 1px solid #2a2a3c; flex-shrink: 0; }
.resizer-col:hover { background: #3a5a8c; }
.right { flex: 1; min-width: 0; height: 100%; min-height: 0; overflow: clip; display: flex; flex-direction: column; }
.panel-right .right { order: 1; }
.panel-right .resizer-col { order: 2; }
.panel-right .parameter-panel { order: 3; }
.stage-area { flex: 1; min-height: 0; overflow: clip; }
.resizer-row { height: 4px; cursor: row-resize; background: #1c1c2a; border-top: 1px solid #2a2a3c; border-bottom: 1px solid #2a2a3c; flex-shrink: 0; }
.resizer-row:hover { background: #3a5a8c; }
+31 -5
View File
@@ -22,12 +22,22 @@ type EditorStore = ReturnType<typeof useParticleStore>
export const EDITOR_CONFIG_FORMAT = 'SpineParticle.EditorConfig'
export const EDITOR_CONFIG_VERSION = 1
export type ProjectSettingsState = Pick<SettingsState,
| 'backgroundX'
| 'backgroundY'
| 'backgroundOpacity'
| 'backgroundImageName'
| 'backgroundImageData'
| 'showGrid'
| 'exportSettings'
>
export interface EditorConfigFile {
format: typeof EDITOR_CONFIG_FORMAT
version: number
savedAt: string
editor: EditorState
settings: SettingsState
settings: ProjectSettingsState
timeline: {
fps: number
loop: boolean
@@ -45,6 +55,18 @@ export interface EditorConfigFile {
}
}
export function captureProjectSettings(settings: SettingsState): ProjectSettingsState {
return {
backgroundX: settings.backgroundX,
backgroundY: settings.backgroundY,
backgroundOpacity: settings.backgroundOpacity,
backgroundImageName: settings.backgroundImageName,
backgroundImageData: settings.backgroundImageData,
showGrid: settings.showGrid,
exportSettings: cloneEditorValue(settings.exportSettings),
}
}
function jsonClone<T>(value: T): T {
return JSON.parse(JSON.stringify(value, (key, item) => {
if (key === 'texture' || key === 'trailTexture' || key === 'frames') return undefined
@@ -53,12 +75,13 @@ function jsonClone<T>(value: T): T {
}
export function createEditorConfig(store: EditorStore): EditorConfigFile {
const settings = captureProjectSettings(store.settings)
return jsonClone({
format: EDITOR_CONFIG_FORMAT,
version: EDITOR_CONFIG_VERSION,
savedAt: new Date().toISOString(),
editor: cloneEditorValue(store.editor),
settings: cloneEditorValue(store.settings),
settings,
timeline: {
fps: store.timeline.fps,
loop: store.timeline.loop,
@@ -201,11 +224,14 @@ export async function applyEditorConfig(store: EditorStore, input: EditorConfigF
store.paths = paths
store.spines = spines
Object.assign(store.editor, cloneEditorValue(config.editor || {}))
Object.assign(store.settings, cloneEditorValue(config.settings || {}))
const projectSettings = config.settings as Partial<ProjectSettingsState> | undefined
if (projectSettings) {
for (const key of ['backgroundX', 'backgroundY', 'backgroundOpacity', 'backgroundImageName', 'backgroundImageData', 'showGrid'] as const) {
if (Object.prototype.hasOwnProperty.call(projectSettings, key)) (store.settings[key] as any) = projectSettings[key]
}
}
store.settings.exportSettings = ensureSpineExportSettings(config.settings?.exportSettings)
store.settings.undoLimit = Math.min(200, Math.max(1, Math.round(Number(store.settings.undoLimit) || 20)))
store.settings.bonePointDrawSize = Math.min(10, Math.max(0.1, Number(config.settings?.bonePointDrawSize) || 1))
store.settings.boneAxisDrawSize = Math.min(10, Math.max(0.1, Number(config.settings?.boneAxisDrawSize) || 2))
store.timeline.fps = Math.max(1, Math.round(Number(config.timeline?.fps) || 30))
store.timeline.loop = config.timeline?.loop !== false
store.timeline.animations = Array.isArray(config.timeline?.animations) && config.timeline.animations.length
+4 -4
View File
@@ -1,5 +1,5 @@
import { reactive, watch, type WatchStopHandle } from 'vue'
import type { useParticleStore, ParticleSystem, CollisionBody, ScenePath, SettingsState } from '../store/particleStore'
import type { useParticleStore, ParticleSystem, CollisionBody, ScenePath } from '../store/particleStore'
import type { SpineSceneObject } from '../spine/spineTypes'
import type { LoadedSpineAsset } from '../spine/spineAssetRegistry'
import {
@@ -9,9 +9,10 @@ import {
retainSpineAssetForHistory,
} from '../spine/spineAssetRegistry'
import { cloneEditorValue } from './editorClone'
import { captureProjectSettings, type ProjectSettingsState } from './editorConfig'
type EditorStore = ReturnType<typeof useParticleStore>
type UndoableSettings = Omit<SettingsState, 'undoLimit'>
type UndoableSettings = ProjectSettingsState
interface EditorSnapshotData {
systems: ParticleSystem[]
@@ -43,14 +44,13 @@ function captureData(store: EditorStore): EditorSnapshotData {
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,
settings: captureProjectSettings(store.settings),
}
}
+119
View File
@@ -0,0 +1,119 @@
import { watch, type WatchStopHandle } from 'vue'
import type { SettingsState, useParticleStore } from '../store/particleStore'
type EditorStore = ReturnType<typeof useParticleStore>
export const SYSTEM_SETTINGS_FORMAT = 'SpineParticle.SystemSettings'
export const SYSTEM_SETTINGS_VERSION = 1
const SYSTEM_SETTINGS_CACHE_KEY = 'SpineParticle.SystemSettings.v1'
export type SystemSettingsState = Pick<SettingsState,
| 'parameterPanelOnRight'
| 'undoLimit'
| 'showEmitter'
| 'showBones'
| 'bonePointDrawSize'
| 'boneAxisDrawSize'
| 'showSkinMesh'
| 'tickEnabled'
| 'axisFontSize'
| 'tickWidth'
| 'showAxisLabels'
| 'axisColor'
>
export interface SystemSettingsFile {
format: typeof SYSTEM_SETTINGS_FORMAT
version: typeof SYSTEM_SETTINGS_VERSION
savedAt: string
settings: SystemSettingsState
}
function clamp(value: unknown, min: number, max: number, fallback: number) {
const number = Number(value)
return Math.min(max, Math.max(min, Number.isFinite(number) ? number : fallback))
}
export function captureSystemSettings(settings: SettingsState): SystemSettingsState {
return {
parameterPanelOnRight: settings.parameterPanelOnRight,
undoLimit: settings.undoLimit,
showEmitter: settings.showEmitter,
showBones: settings.showBones,
bonePointDrawSize: settings.bonePointDrawSize,
boneAxisDrawSize: settings.boneAxisDrawSize,
showSkinMesh: settings.showSkinMesh,
tickEnabled: settings.tickEnabled,
axisFontSize: settings.axisFontSize,
tickWidth: settings.tickWidth,
showAxisLabels: settings.showAxisLabels,
axisColor: settings.axisColor,
}
}
function normalizedSystemSettings(value: Partial<SystemSettingsState>, current: SettingsState): SystemSettingsState {
const booleanValue = <K extends keyof SystemSettingsState>(key: K) => value[key] == null
? Boolean(current[key])
: value[key] === true
const axisColor = typeof value.axisColor === 'string' && /^#[0-9a-f]{6}$/i.test(value.axisColor)
? value.axisColor
: current.axisColor
return {
parameterPanelOnRight: booleanValue('parameterPanelOnRight'),
undoLimit: Math.round(clamp(value.undoLimit, 1, 200, current.undoLimit)),
showEmitter: booleanValue('showEmitter'),
showBones: booleanValue('showBones'),
bonePointDrawSize: clamp(value.bonePointDrawSize, 0.1, 10, current.bonePointDrawSize),
boneAxisDrawSize: clamp(value.boneAxisDrawSize, 0.1, 10, current.boneAxisDrawSize),
showSkinMesh: booleanValue('showSkinMesh'),
tickEnabled: booleanValue('tickEnabled'),
axisFontSize: Math.round(clamp(value.axisFontSize, 8, 40, current.axisFontSize)),
tickWidth: clamp(value.tickWidth, 1, 5, current.tickWidth),
showAxisLabels: booleanValue('showAxisLabels'),
axisColor,
}
}
function parseSystemSettingsCache(text: string): SystemSettingsFile {
let parsed: unknown
try {
parsed = JSON.parse(text)
} catch {
throw new Error('系统设置缓存不是有效的 JSON')
}
const candidate = parsed as Partial<SystemSettingsFile>
if (candidate.format !== SYSTEM_SETTINGS_FORMAT) throw new Error('不是 Spine粒子编辑器系统设置缓存')
if (candidate.version !== SYSTEM_SETTINGS_VERSION) throw new Error(`暂不支持系统配置版本:${candidate.version ?? '未知'}`)
if (!candidate.settings || typeof candidate.settings !== 'object') throw new Error('系统设置缓存缺少 settings')
return candidate as SystemSettingsFile
}
export function applySystemSettings(store: EditorStore, settings: Partial<SystemSettingsState>) {
Object.assign(store.settings, normalizedSystemSettings(settings, store.settings))
}
let stopCacheSync: WatchStopHandle | null = null
/** 启动时读取浏览器缓存,之后将系统设置的每次修改自动同步回缓存。 */
export function installSystemSettingsCache(store: EditorStore) {
stopCacheSync?.()
try {
const cached = localStorage.getItem(SYSTEM_SETTINGS_CACHE_KEY)
if (cached) applySystemSettings(store, parseSystemSettingsCache(cached).settings)
} catch {
try { localStorage.removeItem(SYSTEM_SETTINGS_CACHE_KEY) } catch { /* 浏览器禁用本地存储时使用当前会话默认值。 */ }
}
stopCacheSync = watch(
() => captureSystemSettings(store.settings),
(settings) => {
const file: SystemSettingsFile = {
format: SYSTEM_SETTINGS_FORMAT,
version: SYSTEM_SETTINGS_VERSION,
savedAt: new Date().toISOString(),
settings,
}
try { localStorage.setItem(SYSTEM_SETTINGS_CACHE_KEY, JSON.stringify(file)) } catch { /* 缓存不可用不影响编辑器运行。 */ }
},
{ deep: true },
)
}
+3
View File
@@ -54,6 +54,8 @@ export interface EditorState {
/** 系统设置(可通过右上角设置面板调整) */
export interface SettingsState {
/** 参数面板是否显示在页面右侧;关闭时显示在左侧。 */
parameterPanelOnRight: boolean
/** 是否启用刻度(主开关;启用=显示坐标轴+刻度线+数值,可调整字号/粗细) */
tickEnabled: boolean
/** 坐标轴刻度文字大小(px),默认 12 */
@@ -130,6 +132,7 @@ export const useParticleStore = defineStore('particle', {
// 与 Spine 编辑器默认时间基准对齐:1 秒 = 30 帧。
timeline: { playing: true, loop: true, frame: 0, totalFrames: 60, fps: 30, recorded: false, animation: 'animation', animations: ['animation'] } as TimelineState,
settings: {
parameterPanelOnRight: false,
tickEnabled: false,
axisFontSize: 12,
tickWidth: 1,
+1 -1
View File
@@ -1,7 +1,7 @@
<template>
<div class="panel">
<div class="panel-header">
<span>粒子系统 · 编辑器</span>
<span>Spine粒子编辑器</span>
</div>
<div class="panel-scroll">
+24 -2
View File
@@ -64,6 +64,16 @@
<!-- 系统设置面板(左上角弹出) -->
<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-switch-row">
<span>参数面板位置</span>
<span class="sp-switch-control">
<input type="checkbox" v-model="store.settings.parameterPanelOnRight" aria-label="参数面板显示在右侧" />
<span class="sp-switch-ui" aria-hidden="true"></span>
<b>{{ store.settings.parameterPanelOnRight ? '右侧' : '左侧' }}</b>
</span>
</label>
<div class="sp-divider"></div>
<div class="sp-section-title">操作历史</div>
<label class="sp-row"><span>最大撤回步数</span>
<input
@@ -106,6 +116,7 @@
</label>
<label class="sp-chk"><input type="checkbox" v-model="store.settings.showAxisLabels" />显示刻度数值</label>
</template>
<div class="sp-cache-note">整个系统设置会自动保存到当前浏览器</div>
</div>
<div v-if="showHelp" class="help-overlay" role="presentation" @click.self="showHelp = false">
@@ -136,8 +147,8 @@
<div><dt>小眼睛</dt><dd>单独显示或隐藏场景对象隐藏的粒子系统不会导出</dd></div>
<div><dt>+Copy</dt><dd>完整复制当前选中对象方便对比不同参数效果</dd></div>
<div><dt>背景图</dt><dd>上传参考图并调整位置和透明度只用于画布预览</dd></div>
<div><dt>系统设置</dt><dd>管理撤回步数发射点骨骼蒙皮网格和刻度显示</dd></div>
<div><dt>配置管理</dt><dd>保存当前编辑数据加载配置或直接使用预设文件</dd></div>
<div><dt>系统设置</dt><dd>管理界面与辅助显示并自动保存在当前浏览器中</dd></div>
<div><dt>配置管理</dt><dd>保存当前项目数据加载配置或直接使用预设文件不包含系统设置</dd></div>
<div><dt>Spine 骨骼</dt><dd>在层级树搜索并选择骨骼可用于粒子发射器跟随</dd></div>
</dl>
</section>
@@ -198,6 +209,7 @@ 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 (store.settings.parameterPanelOnRight == null) store.settings.parameterPanelOnRight = false
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
@@ -1932,6 +1944,16 @@ b { color: #ffcc33; }
}
.sp-head { display: flex; justify-content: space-between; align-items: center; font-weight: 700; color: #cdf; margin-bottom: 10px; }
.sp-section-title { margin: 2px 0 8px; color: #8a93bb; font-size: 11px; }
.sp-switch-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; color: #aab; }
.sp-switch-control { display: inline-flex; align-items: center; gap: 7px; }
.sp-switch-control input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
.sp-switch-ui { position: relative; width: 34px; height: 18px; border-radius: 999px; background: #39445a; cursor: pointer; transition: background .16s ease; }
.sp-switch-ui::after { content: ''; position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; border-radius: 50%; background: #f4f6fb; transition: transform .16s ease; }
.sp-switch-control input:checked + .sp-switch-ui { background: #655df0; }
.sp-switch-control input:checked + .sp-switch-ui::after { transform: translateX(16px); }
.sp-switch-control input:focus-visible + .sp-switch-ui { outline: 2px solid #8b86ff; outline-offset: 2px; }
.sp-switch-control b { min-width: 24px; color: #8a93bb; font-size: 11px; font-weight: 500; }
.sp-cache-note { margin-top: 10px; padding-top: 9px; border-top: 1px solid #2a2a3c; color: #647089; font-size: 10px; text-align: center; }
.sp-divider { height: 1px; margin: 10px 0; background: #2a2a3c; }
.sp-close { background: #2e3a55; border: none; color: #cdf; border-radius: 4px; width: 18px; height: 18px; cursor: pointer; font-size: 11px; }
.sp-row { display: flex; align-items: center; gap: 8px; margin: 8px 0; }