阶段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

+70 -8
View File
@@ -8,8 +8,15 @@
<line v-for="g in [0.25,0.5,0.75]" :key="'h'+g" x1="0" :y1="py(g)" :x2="px(1)" :y2="py(g)" />
</g>
<rect :x="px(0)" :y="py(1)" :width="px(1)-px(0)" :height="py(0)-py(1)" class="ce-frame" fill="none" />
<!-- 折线 -->
<polyline :points="polyPoints" class="ce-line" fill="none" />
<path :d="curvePath" class="ce-line" fill="none" />
<g v-if="mode === 'bezier'" class="ce-handles">
<template v-for="(pt, i) in pts" :key="'handles-'+i">
<line v-if="i > 0" :x1="px(pt.x)" :y1="py(pt.y)" :x2="px(inputHandle(i).x)" :y2="py(inputHandle(i).y)" />
<circle v-if="i > 0" :cx="px(inputHandle(i).x)" :cy="py(inputHandle(i).y)" r="4" @pointerdown="onHandleDown($event, i, 'in')" />
<line v-if="i < pts.length - 1" :x1="px(pt.x)" :y1="py(pt.y)" :x2="px(outputHandle(i).x)" :y2="py(outputHandle(i).y)" />
<circle v-if="i < pts.length - 1" :cx="px(outputHandle(i).x)" :cy="py(outputHandle(i).y)" r="4" @pointerdown="onHandleDown($event, i, 'out')" />
</template>
</g>
<!-- 节点:可见圈 r=5 + 命中圈 r=9 -->
<g v-for="(pt,i) in pts" :key="i">
<circle :cx="px(pt.x)" :cy="py(pt.y)" r="5" class="ce-node" />
@@ -17,7 +24,7 @@
@pointerdown="onNodeDown($event,i)" @dblclick="onNodeDbl(i,$event)" />
</g>
</svg>
<div class="ce-nav"><span class="ce-tip">双击空白新建·双击节点删除·拖动节点调整</span></div>
<div class="ce-nav"><span class="ce-tip">{{ mode === 'bezier' ? '拖动方形手柄调整弧度·双击空白新建·双击节点删除' : '双击空白新建·双击节点删除·拖动节点调整' }}</span></div>
</div>
<button class="ce-reset" type="button" title="恢复默认曲线" @click="resetCurve">重置</button>
</div>
@@ -26,15 +33,17 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
type CurvePoint = { x: number; y: number }
type CurvePoint = { x: number; y: number; inX?: number; inY?: number; outX?: number; outY?: number }
const props = withDefaults(defineProps<{
modelValue: CurvePoint[]
defaultValue?: CurvePoint[]
mode?: 'linear' | 'bezier'
}>(), {
defaultValue: () => [{ x: 0, y: 0 }, { x: 1, y: 1 }],
mode: 'linear',
})
const emit = defineEmits<{ (e: 'update:modelValue', v: { x: number; y: number }[]): void }>()
const emit = defineEmits<{ (e: 'update:modelValue', v: CurvePoint[]): void }>()
const svg = ref<SVGSVGElement | null>(null)
const pad = 8 // 内边距(px),避免节点贴边
@@ -54,17 +63,45 @@ function px(x: number) { return pad + x * (W.value - pad * 2) }
// 归一化 y(0-1, y=1 在上) → 像素
function py(y: number) { return pad + (1 - y) * (H.value - pad * 2) }
function normalized(): { x: number; y: number }[] {
function normalized(): CurvePoint[] {
return [...props.modelValue].sort((a, b) => a.x - b.x)
}
function set(v: { x: number; y: number }[]) { emit('update:modelValue', [...v].sort((a, b) => a.x - b.x)) }
function set(v: CurvePoint[]) { emit('update:modelValue', [...v].sort((a, b) => a.x - b.x)) }
function resetCurve() {
set(props.defaultValue.map((point) => ({ ...point })))
}
const pts = computed(() => normalized())
const polyPoints = computed(() => pts.value.map(p => `${px(p.x)},${py(p.y)}`).join(' '))
function defaultInputHandle(index: number) {
const point = pts.value[index], previous = pts.value[Math.max(0, index - 1)]
return { x: point.x - (point.x - previous.x) / 3, y: point.y - (point.y - previous.y) / 3 }
}
function defaultOutputHandle(index: number) {
const point = pts.value[index], next = pts.value[Math.min(pts.value.length - 1, index + 1)]
return { x: point.x + (next.x - point.x) / 3, y: point.y + (next.y - point.y) / 3 }
}
function inputHandle(index: number) {
const point = pts.value[index], fallback = defaultInputHandle(index)
return { x: point.x + (point.inX ?? fallback.x - point.x), y: point.y + (point.inY ?? fallback.y - point.y) }
}
function outputHandle(index: number) {
const point = pts.value[index], fallback = defaultOutputHandle(index)
return { x: point.x + (point.outX ?? fallback.x - point.x), y: point.y + (point.outY ?? fallback.y - point.y) }
}
const curvePath = computed(() => {
if (!pts.value.length) return ''
let path = `M ${px(pts.value[0].x)} ${py(pts.value[0].y)}`
for (let index = 1; index < pts.value.length; index++) {
const point = pts.value[index]
if (props.mode === 'bezier') {
const output = outputHandle(index - 1), input = inputHandle(index)
path += ` C ${px(output.x)} ${py(output.y)} ${px(input.x)} ${py(input.y)} ${px(point.x)} ${py(point.y)}`
} else path += ` L ${px(point.x)} ${py(point.y)}`
}
return path
})
// 客户端坐标 → 归一化 (x:0-1 时间, y:0-1 透明度)
function toNorm(clientX: number, clientY: number) {
@@ -117,6 +154,28 @@ function onNodeDown(ev: PointerEvent, i: number) {
window.addEventListener('pointerup', up)
}
function onHandleDown(ev: PointerEvent, index: number, side: 'in' | 'out') {
ev.stopPropagation()
const move = (event: PointerEvent) => {
const target = toNorm(event.clientX, event.clientY)
const arr = normalized()
const point = arr[index]
const minimumX = side === 'in' ? arr[Math.max(0, index - 1)].x : point.x
const maximumX = side === 'in' ? point.x : arr[Math.min(arr.length - 1, index + 1)].x
const handleX = Math.min(maximumX, Math.max(minimumX, target.x))
const dx = Math.round((handleX - point.x) * 100) / 100
const dy = Math.round((target.y - point.y) * 100) / 100
arr[index] = side === 'in' ? { ...point, inX: dx, inY: dy } : { ...point, outX: dx, outY: dy }
set(arr)
}
const up = () => {
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', up)
}
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
}
// 双击节点删除
function onNodeDbl(i: number, ev?: MouseEvent) {
ev?.stopPropagation()
@@ -146,6 +205,9 @@ onUnmounted(() => { ro?.disconnect() })
.ce-grid line { stroke: #1e1e30; stroke-width: 1; }
.ce-frame { stroke: #2e2e44; stroke-width: 1; }
.ce-line { stroke: #6ea0e8; stroke-width: 2; stroke-linejoin: round; }
.ce-handles line { stroke: #8d96b5; stroke-width: 1; stroke-dasharray: 3 2; }
.ce-handles circle { fill: #b7c2dd; stroke: #344d94; stroke-width: 1.2; cursor: grab; }
.ce-handles circle:active { cursor: grabbing; }
.ce-node { fill: #ffffff; stroke: #3a55aa; stroke-width: 1.6; pointer-events: none; }
.ce-hit { fill: transparent; stroke: none; cursor: grab; }
.ce-hit:active { cursor: grabbing; }
+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; }
+204 -20
View File
@@ -56,6 +56,7 @@
</div>
</div>
<button class="help-btn" :class="{ on: showHelp }" title="查看使用说明" @click="openHelp">使用说明</button>
<span class="zoom-pct">{{ Math.round(store.editor.viewScale * 100) }}%</span>
</div>
@@ -79,6 +80,14 @@
<div class="sp-section-title">画布辅助显示</div>
<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>
<template v-if="store.settings.showBones">
<label class="sp-row"><span>骨骼点绘制大小</span>
<input class="sp-num" type="number" min="0.1" max="10" step="0.1" v-model.number="store.settings.bonePointDrawSize" aria-label="骨骼点绘制大小" @change="normalizeBoneDebugSizes" />
</label>
<label class="sp-row"><span>坐标轴绘制大小</span>
<input class="sp-num" type="number" min="0.1" max="10" step="0.1" v-model.number="store.settings.boneAxisDrawSize" aria-label="坐标轴绘制大小" @change="normalizeBoneDebugSizes" />
</label>
</template>
<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>
@@ -99,6 +108,64 @@
</template>
</div>
<div v-if="showHelp" class="help-overlay" role="presentation" @click.self="showHelp = false">
<section class="help-dialog" role="dialog" aria-modal="true" aria-labelledby="help-title">
<header class="help-head">
<div>
<h2 id="help-title">使用说明</h2>
<p>从创建粒子到保存、导出的快速入门</p>
</div>
<button class="help-close" aria-label="关闭使用说明" title="关闭 (Esc)" @click="showHelp = false">✕</button>
</header>
<div class="help-body">
<section class="help-section help-start">
<h3>快速开始</h3>
<ol>
<li><b>创建对象:</b>在左侧“场景对象”中添加粒子、碰撞、路径或 Spine。</li>
<li><b>编辑粒子:</b>选中粒子系统,依次调整发射、形状、属性、外观资源和修改器。</li>
<li><b>查看效果:</b>使用底部时间轴播放、暂停或拖动到指定帧;修改参数后会自动重新计算。</li>
<li><b>保存成果:</b>配置文件用于继续编辑;“导出 Spine Json”用于导入 Spine 4.2。</li>
</ol>
</section>
<div class="help-grid">
<section class="help-section">
<h3>常用功能</h3>
<dl class="help-list">
<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>Spine 骨骼</dt><dd>在层级树搜索并选择骨骼,可用于粒子发射器跟随。</dd></div>
</dl>
</section>
<section class="help-section">
<h3>快捷键</h3>
<div class="shortcut-list">
<div><kbd>Ctrl/⌘</kbd><span>+</span><kbd>Z</kbd><em>撤回上一步</em></div>
<div><kbd>Ctrl/⌘</kbd><span>+</span><kbd>S</kbd><em>保存配置</em></div>
<div><kbd>Q</kbd><em>拖动画布</em></div>
<div><kbd>W</kbd><em>位移工具</em></div>
<div><kbd>E</kbd><em>旋转工具</em></div>
<div><kbd>R</kbd><em>缩放工具</em></div>
<div><kbd>D</kbd><em>播放 / 暂停</em></div>
<div><kbd>S</kbd><em>回到第一帧</em></div>
</div>
<p class="help-note">Q / W / E / R 需要先点击画布。光标位于输入框时,单键快捷键不会触发。</p>
</section>
</div>
<section class="help-section help-export">
<h3>配置与导出有什么区别?</h3>
<p><b>保存配置</b>会保存编辑器中的全部对象和参数,之后可以继续修改;<b>导出 Spine Json</b>只导出显示中的粒子系统,并把跟随、碰撞和拖尾结果烘焙为独立动画数据。</p>
</section>
</div>
</section>
</div>
<div class="hud" v-if="info">
系统: <b class="hud-number hud-system-count">{{ info.systems }}</b> · 粒子(骨骼): <b class="hud-number hud-particle-count">{{ info.particles }}</b> · 根骨骼: <b>root</b>
<span class="hud-tip">滚轮缩放 · 左键拖动画布</span>
@@ -122,6 +189,7 @@ const holder = ref<HTMLElement | null>(null)
const info = ref<{ systems: number; particles: number } | null>(null)
const showSettings = ref(false)
const showBackgroundMenu = ref(false)
const showHelp = ref(false)
const backgroundFileInput = ref<HTMLInputElement | null>(null)
const backgroundFileName = computed(() => store.settings.backgroundImageName)
// 根变换 gizmo 工具:'translate' | 'rotate' | 'scale' | ''(无)
@@ -137,9 +205,16 @@ if (store.settings.backgroundImageName == null) store.settings.backgroundImageNa
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.bonePointDrawSize > 0)) store.settings.bonePointDrawSize = 1
if (!(store.settings.boneAxisDrawSize > 0)) store.settings.boneAxisDrawSize = 2
if (store.settings.showSkinMesh == null) store.settings.showSkinMesh = false
if (store.settings.showGrid == null) store.settings.showGrid = true
function normalizeBoneDebugSizes() {
store.settings.bonePointDrawSize = Math.min(10, Math.max(0.1, Number(store.settings.bonePointDrawSize) || 1))
store.settings.boneAxisDrawSize = Math.min(10, Math.max(0.1, Number(store.settings.boneAxisDrawSize) || 2))
}
useEditorShortcuts({
canvasElement: holder,
canvasFocused,
@@ -186,8 +261,8 @@ watch(() => [store.activeObjectType, store.activeObjectId], () => selectedPathNo
async function loadTexture() {
if (dotTex && trailTex) return dotTex
const [particleTexture, trailTexture] = await Promise.all([
Texture.fromURL('/particles/star.png'),
Texture.fromURL('/trails/trail.png'),
Texture.fromURL('/images/star.png'),
Texture.fromURL('/images/trail.png'),
])
dotTex = particleTexture
trailTex = trailTexture
@@ -232,6 +307,16 @@ function toggleBackgroundMenu() {
if (showBackgroundMenu.value) showSettings.value = false
}
function openHelp() {
showHelp.value = true
showSettings.value = false
showBackgroundMenu.value = false
}
function onHelpKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && showHelp.value) showHelp.value = false
}
function toggleSettings() {
showSettings.value = !showSettings.value
if (showSettings.value) showBackgroundMenu.value = false
@@ -804,7 +889,7 @@ function syncEmitters() {
const cfg = ensureEmitterConfig(sys.config as any)
if (trailTex) {
for (const resource of cfg.trailResources) {
if (!resource.texture && resource.previewUrl === '/trails/trail.png') resource.texture = trailTex
if (!resource.texture && (resource.previewUrl === '/images/trail.png' || resource.previewUrl === '/trails/trail.png')) resource.texture = trailTex
}
}
if (dotTex) {
@@ -1062,14 +1147,15 @@ function drawGizmo() {
}
// 粒子→骨骼映射:每个活动粒子 = 一根挂在 root 下的骨骼
type BonePreview = { boneName: string; x: number; y: number; parentX?: number; parentY?: number; trail?: boolean }
type BonePreview = { boneName: string; x: number; y: number; rotation: number; parentX?: number; parentY?: number; trail?: boolean }
function appendStateBones(target: BonePreview[], state: ParticleState) {
target.push({ boneName: state.boneName, x: state.x, y: state.y })
// 粒子 rotation 使用数学角度,而 Pixi 画布 Y 轴向下,因此显示轴取反。
target.push({ boneName: state.boneName, x: state.x, y: state.y, rotation: -state.rotation })
if (!state.trailState?.bones.length) return
state.trailState.bones.forEach((bone, index, bones) => {
const parent = index > 0 ? bones[index - 1] : { x: state.x, y: state.y }
target.push({ boneName: bone.boneName, x: bone.x, y: bone.y, parentX: parent.x, parentY: parent.y, trail: true })
state.trailState.bones.forEach((bone) => {
// 导出结构中拖尾骨骼全部直接挂在粒子系统骨骼下,画布连线也保持同级语义。
target.push({ boneName: bone.boneName, x: bone.x, y: bone.y, rotation: bone.rotation, parentX: 0, parentY: 0, trail: true })
})
}
@@ -1169,6 +1255,8 @@ function bakeLoopFrames(system: ParticleSystem, emitter: ParticleEmitter, fps: n
const finalFrame = cloneParticleFrame(frames[frames.length - 1] || [])
if (frames.length > totalFrames) frames.length = totalFrames
while (frames.length < totalFrames) frames.push(cloneParticleFrame(finalFrame))
// 循环持续拥有自己的衔接帧规则:纯循环和结束段允许第 0 帧保留骨骼;
// 启动段则已在上方按定义生成空首帧。
system.frames = frames
emitter.apply(frames[0] || [])
}
@@ -1179,15 +1267,36 @@ function drawBones(bones: BonePreview[]) {
g.clear()
if (!store.settings.showBones) return
const cx = 0, cy = 0
const viewScale = Math.max(0.25, Math.abs(store.editor.viewScale) || 1)
const pointSize = Math.min(10, Math.max(0.1, Number(store.settings.bonePointDrawSize) || 1))
const axisSize = Math.min(10, Math.max(0.1, Number(store.settings.boneAxisDrawSize) || 2))
// 所有尺寸先定义为屏幕像素,再除以画布缩放,保证缩放画布时视觉大小不变。
const pointRadius = 2.5 * pointSize / viewScale
const forwardLength = 4.5 * axisSize / viewScale
const sideLength = 2.5 * axisSize / viewScale
const forwardWidth = Math.max(0.65, axisSize * 0.625) / viewScale
const sideWidth = Math.max(0.55, axisSize * 0.5) / viewScale
// root → 每颗粒子骨骼 的连线
for (const b of bones) {
const color = b.trail ? 0xff4d8d : 0xff8c5a
g.lineStyle(1, color, b.trail ? 0.65 : 0.35)
g.moveTo(b.parentX ?? cx, b.parentY ?? cy)
g.lineTo(b.x, b.y)
// 小型局部方向轴:青色为骨骼正方向,黄色为垂直方向。
const angle = b.rotation * Math.PI / 180
const cos = Math.cos(angle), sin = Math.sin(angle)
g.lineStyle(forwardWidth, 0x38d9ff, 0.95)
g.moveTo(b.x, b.y)
g.lineTo(b.x + cos * forwardLength, b.y + sin * forwardLength)
g.beginFill(0x38d9ff, 0.95)
g.drawCircle(b.x + cos * forwardLength, b.y + sin * forwardLength, Math.max(0.8, axisSize * 0.625) / viewScale)
g.endFill()
g.lineStyle(sideWidth, 0xffd166, 0.85)
g.moveTo(b.x, b.y)
g.lineTo(b.x - sin * sideLength, b.y + cos * sideLength)
// 骨骼点
g.beginFill(color, 0.9)
g.drawCircle(b.x, b.y, 2.5)
g.drawCircle(b.x, b.y, pointRadius)
g.endFill()
}
}
@@ -1289,14 +1398,28 @@ function drawPaths() {
}
}
function followCurveValue(points: Array<{ x: number; y: number }>, t: number) {
type FollowCurvePoint = { x: number; y: number; inX?: number; inY?: number; outX?: number; outY?: number }
function followCurveValue(points: FollowCurvePoint[], t: number, mode: 'linear' | 'bezier' = 'linear') {
if (!points.length) return t
const sorted = [...points].sort((a, b) => a.x - b.x)
if (t <= sorted[0].x) return sorted[0].y
for (let index = 1; index < sorted.length; index++) {
const a = sorted[index - 1], b = sorted[index]
if (t <= b.x) {
const amount = (t - a.x) / Math.max(0.0001, b.x - a.x)
let amount = (t - a.x) / Math.max(0.0001, b.x - a.x)
if (mode === 'bezier') {
const x1 = a.x + (a.outX ?? (b.x - a.x) / 3)
const x2 = b.x + (b.inX ?? -(b.x - a.x) / 3)
let low = 0, high = 1
for (let step = 0; step < 18; step++) {
const middle = (low + high) / 2
if (cubicPoint(a.x, x1, x2, b.x, middle) < t) low = middle
else high = middle
}
amount = (low + high) / 2
return cubicPoint(a.y, a.y + (a.outY ?? (b.y - a.y) / 3), b.y + (b.inY ?? -(b.y - a.y) / 3), b.y, amount)
}
return a.y + (b.y - a.y) * amount
}
}
@@ -1352,9 +1475,10 @@ function buildPreviewColliders(timeSeconds: number) {
const duration = Math.max(0.05, Number(collider.followDuration) || 2)
const normalized = Math.min(1, Math.max(0, timeSeconds / duration))
const curve = Array.isArray(collider.followCurve) ? collider.followCurve : [{ x: 0, y: 0 }, { x: 1, y: 1 }]
const progress = collider.followCurveMode === '线性'
? normalized
: Math.min(1, Math.max(0, followCurveValue(curve, normalized)))
const curveMode = collider.followCurveMode === '跟随导出'
? (store.settings.exportSettings.keyframeCurve === 'bezier' ? 'bezier' : 'linear')
: collider.followCurveMode === '贝塞尔' ? 'bezier' : 'linear'
const progress = Math.min(1, Math.max(0, followCurveValue(curve, normalized, curveMode)))
const sample = sampleScenePath(path, progress)
return sample
? {
@@ -1408,6 +1532,7 @@ function loop() {
for (const sys of store.systems) {
const em = emitterMap.get(sys.id)
if (!em) continue
em.setFollowExportCurveMode(store.settings.exportSettings.keyframeCurve === 'bezier' ? '贝塞尔' : '线性')
const visible = sys.visible !== false
em.visible = visible
em.renderable = visible
@@ -1520,11 +1645,14 @@ function loop() {
}
for (const [systemId, em] of emitterMap) {
const system = store.systems.find((item) => item.id === systemId)
const loopFrame = system?.config.mode === 'stream' && system.config.streamBehavior === 'loop'
const isLoopStream = system?.config.mode === 'stream' && system.config.streamBehavior === 'loop'
const loopFrame = isLoopStream
? system.frames?.[tl.frame]
: undefined
const states = loopFrame || em.update(frameDuration)
if (loopFrame) em.apply(loopFrame)
const forceEmptyInitialFrame = tl.frame === 0 && !isLoopStream
const states = forceEmptyInitialFrame ? [] : (loopFrame || em.update(frameDuration))
if (forceEmptyInitialFrame) em.apply([])
else if (loopFrame) em.apply(loopFrame)
const systemVisible = system?.visible !== false
if (systemVisible) for (const state of states) if (state.active) appendStateBones(collected, state)
total += states.length
@@ -1576,7 +1704,9 @@ function loop() {
total++
}
} else if (emitter) {
const states = emitter.update(0)
// 尚未录制时,第 0 帧也必须保持绝对空白,不能由 update(0) 提前触发发射。
const states = tl.frame === 0 ? [] : emitter.update(0)
if (tl.frame === 0) emitter.apply([])
if (sys.visible !== false) for (const state of states) if (state.active) appendStateBones(collected, state)
total += emitter.activeCount
}
@@ -1599,6 +1729,7 @@ function loop() {
}
onMounted(async () => {
window.addEventListener('keydown', onHelpKeydown)
const el = holder.value!
app = new Application({ width: el.clientWidth || 800, height: el.clientHeight || 600, backgroundAlpha: 0, antialias: true })
el.appendChild(app.view as unknown as HTMLCanvasElement)
@@ -1611,7 +1742,7 @@ onMounted(async () => {
const cfg = ensureEmitterConfig(sys.config as any)
if (trailTex) {
for (const resource of cfg.trailResources) {
if (!resource.texture && resource.previewUrl === '/trails/trail.png') resource.texture = trailTex
if (!resource.texture && (resource.previewUrl === '/images/trail.png' || resource.previewUrl === '/trails/trail.png')) resource.texture = trailTex
}
}
for (const resource of cfg.imageResources) {
@@ -1652,6 +1783,7 @@ onMounted(async () => {
})
onUnmounted(() => {
window.removeEventListener('keydown', onHelpKeydown)
cancelAnimationFrame(raf)
spineRuntime?.destroy()
spineRuntime = null
@@ -1723,7 +1855,59 @@ onUnmounted(() => {
.background-opacity-row { display: grid; grid-template-columns: 46px 1fr 34px; align-items: center; gap: 7px; margin-top: 10px; font-size: 11px; }
.background-opacity-row input { min-width: 0; accent-color: #675de0; }
.background-opacity-row b { color: #aeb8cc; font-size: 10px; text-align: right; font-variant-numeric: tabular-nums; }
.zoom-pct { margin-left: auto; font-variant-numeric: tabular-nums; color: #9aa; min-width: 44px; text-align: center; }
.help-btn {
height: 28px; margin-left: auto; padding: 0 11px; flex: 0 0 auto; border: 1px solid #3a4963; border-radius: 6px;
background: #202a3b; color: #cbd7ed; font-size: 12px; font-weight: 700; cursor: pointer;
}
.help-btn:hover, .help-btn.on { border-color: #7774ff; background: #343b63; color: #fff; }
.zoom-pct { margin-left: 0; font-variant-numeric: tabular-nums; color: #9aa; min-width: 44px; text-align: center; }
.help-overlay {
position: absolute; inset: 0; z-index: 40; display: flex; align-items: center; justify-content: center; padding: 72px 24px 28px;
box-sizing: border-box; background: rgba(5, 8, 15, .72); backdrop-filter: blur(3px);
}
.help-dialog {
width: min(820px, 100%); max-height: 100%; display: flex; flex-direction: column; overflow: hidden;
border: 1px solid #3a4864; border-radius: 14px; background: #141a27; color: #b8c3d8;
box-shadow: 0 22px 70px rgba(0, 0, 0, .62);
}
.help-head {
display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; padding: 18px 20px 15px;
border-bottom: 1px solid #2b354a; background: linear-gradient(135deg, #202942, #181e2d);
}
.help-head h2 { margin: 0; color: #eef2ff; font-size: 19px; }
.help-head p { margin: 5px 0 0; color: #7786a3; font-size: 11px; }
.help-close {
width: 28px; height: 28px; flex: 0 0 28px; border: 1px solid #43516b; border-radius: 6px;
background: #273249; color: #ccd7ed; cursor: pointer;
}
.help-close:hover { border-color: #7774ff; background: #3b4670; color: #fff; }
.help-body { min-height: 0; overflow: auto; padding: 18px 20px 21px; }
.help-section { padding: 14px 15px; border: 1px solid #2c3850; border-radius: 10px; background: #181f2e; }
.help-section h3 { margin: 0 0 11px; color: #9fb2ff; font-size: 13px; }
.help-start ol { margin: 0; padding-left: 21px; color: #aab6cc; font-size: 12px; line-height: 1.75; }
.help-start li::marker { color: #7774ff; font-weight: 700; }
.help-section b { color: #dce4f7; }
.help-grid { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(250px, .85fr); gap: 12px; margin-top: 12px; }
.help-list { display: grid; gap: 0; margin: 0; }
.help-list > div { display: grid; grid-template-columns: 78px 1fr; gap: 9px; padding: 7px 0; border-bottom: 1px solid #263146; }
.help-list > div:last-child { border-bottom: 0; }
.help-list dt { color: #d3dcf0; font-size: 11px; font-weight: 700; }
.help-list dd { margin: 0; color: #8795ae; font-size: 11px; line-height: 1.5; }
.shortcut-list { display: grid; gap: 7px; }
.shortcut-list > div { display: flex; align-items: center; min-height: 25px; gap: 5px; }
.shortcut-list kbd {
min-width: 24px; box-sizing: border-box; padding: 3px 6px; border: 1px solid #465470; border-bottom-width: 2px;
border-radius: 5px; background: #101623; color: #e0e7f6; font: 10px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; text-align: center;
}
.shortcut-list span { color: #65728b; font-size: 10px; }
.shortcut-list em { margin-left: auto; color: #9ca9c1; font-size: 11px; font-style: normal; }
.help-note { margin: 11px 0 0; padding-top: 9px; border-top: 1px solid #2a3549; color: #697791; font-size: 10px; line-height: 1.55; }
.help-export { margin-top: 12px; }
.help-export p { margin: 0; color: #8997b0; font-size: 11px; line-height: 1.65; }
@media (max-width: 760px) {
.help-overlay { padding: 66px 12px 14px; }
.help-grid { grid-template-columns: 1fr; }
}
.hud { position: absolute; bottom: 12px; left: 12px; padding: 8px 12px; background: rgba(20,20,32,0.75); border-radius: 8px; font-size: 12px; color: #a0a8cc; }
.hud-number { display: inline-block; box-sizing: border-box; text-align: right; font-variant-numeric: tabular-nums; font-feature-settings: 'tnum' 1; }
.hud-system-count { width: 3ch; }