阶段11开发
This commit is contained in:
1 parent
8e9b83f95b
commit
8802bbe25e
21 files changed
+5285
-81
No files matched your search
+175
-17
@@ -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; }
|
||||
|
||||
Reference in new issue
Block a user