阶段11开发

This commit is contained in:
tianmo committed 2026-08-31 21:01:53 +08:00
1 parent 8e9b83f95b
commit 8802bbe25e
21 files changed
+5285 -81

No files matched your search

+175 -17
View File
@@ -175,6 +175,7 @@
<CurveEditor
:model-value="activeCollider.followCurve || LINEAR_CURVE"
:default-value="LINEAR_CURVE"
:mode="resolvedCurveEditorMode(activeCollider.followCurveMode)"
@update:model-value="activeCollider.followCurve = $event"
/>
</template>
@@ -455,7 +456,7 @@
</select>
</label>
<div class="curve-wrap">
<CurveEditor v-model="sys.config.alphaCurve" :default-value="ALPHA_CURVE" />
<CurveEditor v-model="sys.config.alphaCurve" :default-value="ALPHA_CURVE" :mode="resolvedCurveEditorMode(sys.config.curveMode)" />
<div class="curve-labels"><span>0(透明)</span><span>生命周期 →</span><span>1(不透明)</span></div>
</div>
</template>
@@ -518,7 +519,7 @@
</select>
</label>
<div class="resource-alpha-curve">
<CurveEditor v-model="resource.alphaCurve" :default-value="RESOURCE_ALPHA_CURVE" />
<CurveEditor v-model="resource.alphaCurve" :default-value="RESOURCE_ALPHA_CURVE" :mode="resolvedCurveEditorMode(resource.alphaCurveMode)" />
</div>
</template>
</ImageResourceCard>
@@ -663,8 +664,29 @@
<div class="resource-divider"></div>
<NumSlider label="旋转" :min="-360" :max="360" :step="1" :model-value="resource.rotation" @update:model-value="onTrailResourceRotationChange(resource.id, $event)" />
<NumSlider label="拖尾骨骼数" :min="2" :max="16" :step="1" :model-value="resource.boneCount" @update:model-value="onTrailResourceBoneCountChange(resource.id, $event)" />
<NumSlider label="初始长度" :min="0" :max="1000" :step="1" v-model="resource.length" />
<NumSlider label="初始宽度" :min="0" :max="1000" :step="1" v-model="resource.width" />
<NumSlider label="拖尾比例 (%)" :min="0" :max="100" :step="1" v-model="resource.trailRatio" />
<label class="row"><span>拖尾长度</span>
<select v-model="resource.lengthMode" class="inp">
<option value="fixed">固定</option>
<option value="random">随机范围</option>
</select>
</label>
<NumSlider v-if="resource.lengthMode === 'fixed'" label="长度比例 (%)" :min="0" :max="500" :step="1" v-model="resource.length" />
<template v-else>
<NumSlider label="长度最小 (%)" :min="0" :max="500" :step="1" v-model="resource.lengthMin" />
<NumSlider label="长度最大 (%)" :min="0" :max="500" :step="1" v-model="resource.lengthMax" />
</template>
<label class="row"><span>拖尾宽度</span>
<select v-model="resource.widthSizeMode" class="inp">
<option value="fixed">固定</option>
<option value="random">随机范围</option>
</select>
</label>
<NumSlider v-if="resource.widthSizeMode === 'fixed'" label="宽度比例 (%)" :min="0" :max="500" :step="1" v-model="resource.width" />
<template v-else>
<NumSlider label="宽度最小 (%)" :min="0" :max="500" :step="1" v-model="resource.widthMin" />
<NumSlider label="宽度最大 (%)" :min="0" :max="500" :step="1" v-model="resource.widthMax" />
</template>
<label class="row"><span>宽度缩放</span>
<select v-model="resource.widthMode" class="inp">
<option value="particle">跟随粒子</option>
@@ -721,7 +743,7 @@
<NumSlider label="网格行数" :min="2" :max="16" :step="1" :model-value="sys.config.trailGridRows" @update:model-value="onTrailRigChange('trailGridRows', $event)" />
<NumSlider label="网格列数" :min="2" :max="32" :step="1" :model-value="sys.config.trailGridCols" @update:model-value="onTrailRigChange('trailGridCols', $event)" />
</div>
<div class="modifier-hint">每个粒子生成独立拖尾骨骼链;网格按行列自动重建,并由相邻骨骼权重共同驱动蒙皮变形</div>
<div class="modifier-hint">每个粒子按资源比例生成一组同级拖尾骨骼;随机长度与宽度在粒子出生时确定,相同种子可稳定复现</div>
</div>
</div>
<div class="modifier-item">
@@ -791,20 +813,66 @@
</div>
</div>
<!-- 7. 导出设置(预留) -->
<!-- 7. 导出设置:当前阶段只维护参数,后续接入 JSON 生成。 -->
<div class="group">
<div class="group-head" @click="toggle('export')">
<span class="caret">{{ collapsed.export ? '▸' : '▾' }}</span><span>导出设置</span><span class="tag">预留</span>
<span class="caret">{{ collapsed.export ? '▸' : '▾' }}</span><span>导出设置</span>
<button class="group-reset" title="重置导出设置" @click.stop="resetModule('export')">重置</button>
</div>
<div class="group-body" v-show="!collapsed.export">
<div class="placeholder">导出功能后续实现,已预留位置</div>
<div class="group-body export-settings" v-show="!collapsed.export">
<label class="export-field">
<span>图片路径 (Spine Images Path)</span>
<input v-model.trim="exportSettings.imagesPath" class="inp export-input" type="text" placeholder="./images/" />
</label>
<label class="export-field">
<span>Spine 版本</span>
<select v-model="exportSettings.spineVersion" class="inp export-input">
<option value="4.2">Spine 4.2</option>
</select>
</label>
<label class="export-field">
<span>关键帧插值曲线</span>
<select v-model="exportSettings.keyframeCurve" class="inp export-input">
<option value="linear">线性 (Linear)</option>
<option value="bezier">平滑贝塞尔 (Smooth Bézier)</option>
</select>
</label>
<div class="export-help">平滑贝塞尔会在误差允许范围内拟合连续运动;出生、死亡、附件切换和突变仍使用阶跃关键帧。</div>
<div class="export-fps-row">
<label class="export-field export-fps-field">
<span>导出 FPS</span>
<input v-model.number="exportSettings.fps" class="inp export-input" type="number" min="1" max="240" step="1" @change="normalizeExportFps" />
</label>
<label class="export-checkbox"><input v-model="exportSettings.omitFps" type="checkbox" />不写 fps</label>
</div>
<div class="export-help">修改 FPS 时保持动画实际秒数不变;“不写 fps”只省略 Spine 的 fps 元数据。</div>
<div class="export-switches">
<label class="switch-row export-switch">
<input v-model="exportSettings.integerFrameAlignment" type="checkbox" />
<span class="switch-ui"></span><span>整数帧对齐</span>
</label>
<label class="switch-row export-switch">
<input v-model="exportSettings.bonePool" type="checkbox" />
<span class="switch-ui"></span><span>骨骼对象池</span>
</label>
<label class="switch-row export-switch export-switch-last">
<input v-model="exportSettings.excludeEmptyAnimations" type="checkbox" />
<span class="switch-ui"></span><span>排除无内容动画</span>
</label>
</div>
</div>
</div>
</template>
</div>
<button type="button" class="export-spine-json" title="导出功能将在后续阶段实现">导出Spine Json</button>
<button type="button" class="export-spine-json" :disabled="exportBusy" :title="exportBusy ? '正在重新计算并烘焙动画' : '导出可重新导入 Spine 4.2 的 JSON'" @click="exportSpineJson">
{{ exportBusy ? '正在准备导出…' : '导出Spine Json' }}
</button>
<div v-if="exportMessage" class="export-message" :class="{ error: exportError }">{{ exportMessage }}</div>
</div>
</template>
@@ -812,6 +880,7 @@
import { computed, markRaw, onMounted, reactive, ref, watchEffect } from 'vue'
import { Texture } from 'pixi.js'
import { useParticleStore } from '../store/particleStore'
import type { ParticleSystem } from '../store/particleStore'
import { defaultConfig, ensureEmitterConfig } from '../core/particleEmitter'
import NumSlider from './NumSlider.vue'
import CurveEditor from './CurveEditor.vue'
@@ -820,14 +889,27 @@ import ColorGradientEditor from './ColorGradientEditor.vue'
import ImageResourceCard from './ImageResourceCard.vue'
import SpineObjectPanel from '../spine/SpineObjectPanel.vue'
import { downloadEditorConfig, loadEditorConfigFile, loadEditorConfigText } from '../editor/editorConfig'
import { defaultSpineExportSettings, ensureSpineExportSettings } from '../export/spineExportSettings'
import { buildSpineJson, downloadSpineJson } from '../export/spineJsonExporter'
import { validateSpineJson } from '../export/spineJsonValidator'
const store = useParticleStore()
store.settings.exportSettings = ensureSpineExportSettings(store.settings.exportSettings)
const exportSettings = computed(() => store.settings.exportSettings)
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)
const exportBusy = ref(false)
const exportMessage = ref('')
const exportError = ref(false)
function resolvedCurveEditorMode(mode: '跟随导出' | '线性' | '贝塞尔') {
if (mode === '跟随导出') return exportSettings.value.keyframeCurve === 'bezier' ? 'bezier' : 'linear'
return mode === '贝塞尔' ? 'bezier' : 'linear'
}
// 默认场景始终有一个可编辑的粒子系统;热更新保留现有系统时不重复创建。
if (!store.systems.length) store.addSystem()
@@ -890,6 +972,46 @@ async function loadSelectedPreset() {
onMounted(refreshPresets)
function nextPaint() {
return new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
}
async function prepareExportFrames() {
store.timeline.playing = false
store.timeline.frame = 0
store.timeline.recorded = false
for (const system of store.systems) system.frames = undefined
store.recalcTotalFrames()
await nextPaint()
store.timeline.playing = true
const expectedMs = store.timeline.totalFrames / Math.max(1, store.timeline.fps) * 1000
const deadline = performance.now() + Math.max(8000, expectedMs * 2.5 + 3000)
while (!store.timeline.recorded) {
if (performance.now() > deadline) throw new Error('重新计算动画超时,请确认页面仍在运行后重试')
await new Promise((resolve) => setTimeout(resolve, 50))
}
}
async function exportSpineJson() {
if (exportBusy.value) return
exportBusy.value = true
exportError.value = false
exportMessage.value = '正在重新计算粒子动画…'
try {
await prepareExportFrames()
const result = buildSpineJson({ systems: store.systems as unknown as ParticleSystem[], timeline: store.timeline, settings: exportSettings.value })
validateSpineJson(result.text)
downloadSpineJson(result, exportSettings.value.fileName)
const warning = result.warnings.length ? `;${result.warnings.length} 条资源提示(缺图仍已导出)` : ''
exportMessage.value = `已导出 ${result.particleSystemCount} 个粒子系统、${result.animationCount} 个动画、${result.boneCount} 根骨骼${warning}`
} catch (error) {
exportError.value = true
exportMessage.value = error instanceof Error ? error.message : String(error)
} finally {
exportBusy.value = false
}
}
const activeIdx = computed(() => {
const i = store.systems.findIndex((s) => s.id === store.activeId)
return i >= 0 ? i : 0
@@ -993,7 +1115,7 @@ const collapsed = reactive<Record<string, boolean>>({
attr: true,
look: true,
mods: true,
export: true,
export: false,
})
function toggle(key: string) { collapsed[key] = !collapsed[key] }
@@ -1143,12 +1265,20 @@ const MODULE_RESET_KEYS: Record<Exclude<ResetModule, 'export'>, string[]> = {
function resetModule(module: ResetModule) {
const config = sys.value?.config as any
if (!config || module === 'export') return
if (module === 'export') {
store.settings.exportSettings = defaultSpineExportSettings()
return
}
if (!config) return
const defaults = defaultConfig() as any
for (const key of MODULE_RESET_KEYS[module]) config[key] = defaults[key]
// 重置资源模块后,Stage 会在下一帧重新挂载默认 star/trail 纹理。
}
function normalizeExportFps() {
exportSettings.value.fps = Math.min(240, Math.max(1, Math.round(Number(exportSettings.value.fps) || 30)))
}
function select(i: number) { store.selectSystem(store.systems[i]?.id ?? 0) }
function addSys() { store.addSystem() }
function addCollider() { store.addCollider() }
@@ -1260,7 +1390,7 @@ function addTrailResource() {
id: nextId,
texture: null,
textureName: 'trail',
previewUrl: '/trails/trail.png',
previewUrl: '/images/trail.png',
weight: 100,
locked: false,
rotation: 0,
@@ -1272,8 +1402,15 @@ function addTrailResource() {
alpha: 1,
alphaCurve: TRAIL_ALPHA_CURVE.map((point) => ({ ...point })),
boneCount: 3,
trailRatio: 100,
lengthMode: 'fixed',
length: 100,
lengthMin: 100,
lengthMax: 100,
widthSizeMode: 'fixed',
width: 100,
widthMin: 100,
widthMax: 100,
widthMode: 'particle',
lifeLengthEnabled: false,
lifeLengthCurve: FLAT_CURVE.map((point) => ({ ...point })),
@@ -1329,15 +1466,22 @@ async function resetTrailTexture(resourceId: number) {
const config = sys.value?.config
const resource = config?.trailResources.find((item) => item.id === resourceId)
if (!resource) return
resource.texture = markRaw(await Texture.fromURL('/trails/trail.png')) as any
resource.previewUrl = '/trails/trail.png'
resource.texture = markRaw(await Texture.fromURL('/images/trail.png')) as any
resource.previewUrl = '/images/trail.png'
resource.textureName = 'trail'
resource.weight = 100
resource.locked = false
resource.rotation = 0
resource.boneCount = 3
resource.trailRatio = 100
resource.lengthMode = 'fixed'
resource.length = 100
resource.lengthMin = 100
resource.lengthMax = 100
resource.widthSizeMode = 'fixed'
resource.width = 100
resource.widthMin = 100
resource.widthMax = 100
resource.widthMode = 'particle'
resource.lifeLengthEnabled = false
resource.lifeLengthCurve = FLAT_CURVE.map((point) => ({ ...point }))
@@ -1429,7 +1573,7 @@ function addImageResource() {
texture: config.texture,
textureName: 'star',
textureFolder: '',
previewUrl: '/particles/star.png',
previewUrl: '/images/star.png',
imageMode: 'fixed',
sequencePlayback: 'loop-forward',
sequenceFps: 30,
@@ -1513,7 +1657,7 @@ function resetTexture(resourceId: number) {
resource.texture = config.texture
resource.textureName = 'star'
resource.textureFolder = ''
resource.previewUrl = '/particles/star.png'
resource.previewUrl = '/images/star.png'
resource.imageMode = 'fixed'
resource.sequencePlayback = 'loop-forward'
resource.sequenceFps = 30
@@ -1744,8 +1888,22 @@ function toggleResourceLock(resourceId: number) {
.mini-btn { background: #2e3a55; border: 1px solid #3a4a6a; color: #cdf; border-radius: 5px; padding: 3px 8px; font-size: 11px; cursor: pointer; }
.mini-btn.danger { background: #4a2a2e; color: #fbb; }
.placeholder { color: #667; font-size: 11px; padding: 6px 0; }
.export-settings { padding-top: 3px; }
.export-field { display: flex; flex-direction: column; gap: 6px; margin: 10px 0 13px; color: #9ca8bf; font-size: 11px; font-weight: 600; }
.export-input { width: 100%; min-height: 34px; box-sizing: border-box; padding: 6px 9px; color: #e3e7f2; background: #141a29; border-color: #35445f; }
.export-fps-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: end; }
.export-fps-field { margin-bottom: 5px; }
.export-checkbox { display: flex; align-items: center; gap: 7px; min-height: 34px; margin-bottom: 5px; color: #aeb8cc; font-size: 11px; white-space: nowrap; }
.export-checkbox input { width: 17px; height: 17px; margin: 0; accent-color: #625ff1; }
.export-help { margin: -6px 0 14px; color: #66758e; font-size: 10px; line-height: 1.5; }
.export-switches { display: grid; gap: 13px; margin: 4px 0 7px; padding-top: 2px; }
.export-switch { font-weight: 600; }
.export-switch-last { margin-top: 13px; }
.export-spine-json { width: 100%; margin-top: 10px; padding: 9px 12px; flex: 0 0 auto; border: 1px solid #4f4b9a; border-radius: 7px; background: #403b91; color: #f1f0ff; font-size: 12px; font-weight: 700; cursor: pointer; }
.export-spine-json:hover { border-color: #7774ff; background: #514bc0; }
.export-spine-json:disabled { cursor: wait; opacity: .68; }
.export-message { padding: 7px 5px 0; color: #70d8ae; font-size: 10px; line-height: 1.45; text-align: center; }
.export-message.error { color: #ff858f; }
/* 外观与资源 参考版式 */
.look-title { font-size: 12px; font-weight: 600; color: #ccd; margin: 10px 0 6px; padding-bottom: 4px; border-bottom: 1px solid #2a2a3c; }
.look-title:first-child { margin-top: 2px; }