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

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
+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; }