显示调整
This commit is contained in:
1 parent
178a7d8412
commit
b5ac9552a1
9 files changed
+1064
-81
No files matched your search
+149
-25
@@ -26,6 +26,9 @@
|
||||
>
|
||||
<span class="tdot">●</span>
|
||||
<span class="tlabel">{{ system.config.name || 'ParticleSystem' + (i + 1) }}</span>
|
||||
<button class="scene-object-eye" :class="{ off: system.visible === false }" :title="system.visible === false ? '显示粒子系统' : '隐藏粒子系统'" :aria-label="system.visible === false ? '显示粒子系统' : '隐藏粒子系统'" @click.stop="system.visible = system.visible === false">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"/><circle cx="12" cy="12" r="2.8"/><path v-if="system.visible === false" class="eye-slash" d="M4 4l16 16"/></svg>
|
||||
</button>
|
||||
<span class="scene-object-kind">粒子</span>
|
||||
<button class="scene-object-delete" title="删除粒子系统" @click.stop="store.removeSystem(system.id)">✕</button>
|
||||
</div>
|
||||
@@ -38,6 +41,9 @@
|
||||
>
|
||||
<span class="tdot collision-dot">■</span>
|
||||
<span class="tlabel">{{ collider.name }}</span>
|
||||
<button class="scene-object-eye" :class="{ off: collider.visible === false }" :title="collider.visible === false ? '显示碰撞体' : '隐藏碰撞体'" :aria-label="collider.visible === false ? '显示碰撞体' : '隐藏碰撞体'" @click.stop="collider.visible = collider.visible === false">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"/><circle cx="12" cy="12" r="2.8"/><path v-if="collider.visible === false" class="eye-slash" d="M4 4l16 16"/></svg>
|
||||
</button>
|
||||
<span class="scene-object-kind">碰撞</span>
|
||||
<button class="scene-object-delete" title="删除碰撞体" @click.stop="store.removeCollider(collider.id)">✕</button>
|
||||
</div>
|
||||
@@ -50,6 +56,9 @@
|
||||
>
|
||||
<span class="tdot path-dot">◇</span>
|
||||
<span class="tlabel">{{ path.name }}</span>
|
||||
<button class="scene-object-eye" :class="{ off: path.visible === false }" :title="path.visible === false ? '显示路径' : '隐藏路径'" :aria-label="path.visible === false ? '显示路径' : '隐藏路径'" @click.stop="path.visible = path.visible === false">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"/><circle cx="12" cy="12" r="2.8"/><path v-if="path.visible === false" class="eye-slash" d="M4 4l16 16"/></svg>
|
||||
</button>
|
||||
<span class="scene-object-kind">路径</span>
|
||||
<button class="scene-object-delete" title="删除路径" @click.stop="store.removePath(path.id)">✕</button>
|
||||
</div>
|
||||
@@ -62,6 +71,9 @@
|
||||
>
|
||||
<span class="tdot spine-dot">◆</span>
|
||||
<span class="tlabel">{{ spine.name }}</span>
|
||||
<button class="scene-object-eye" :class="{ off: spine.visible === false }" :title="spine.visible === false ? '显示 Spine' : '隐藏 Spine'" :aria-label="spine.visible === false ? '显示 Spine' : '隐藏 Spine'" @click.stop="spine.visible = spine.visible === false">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"/><circle cx="12" cy="12" r="2.8"/><path v-if="spine.visible === false" class="eye-slash" d="M4 4l16 16"/></svg>
|
||||
</button>
|
||||
<span class="scene-object-kind">Spine</span>
|
||||
<button class="scene-object-delete" title="删除 Spine" @click.stop="store.removeSpine(spine.id)">✕</button>
|
||||
</div>
|
||||
@@ -679,33 +691,61 @@
|
||||
<span class="switch-ui"></span><span>开启发射器跟随</span>
|
||||
</label>
|
||||
<div v-if="sys.config.emitterFollow" class="modifier-params emitter-follow-params">
|
||||
<label class="field-block"><span>跟随模式</span><select v-model="sys.config.emitterFollowMode" class="inp">
|
||||
<label class="field-block"><span>跟随模式</span><select :value="sys.config.emitterFollowMode" class="inp" @change="onEmitterFollowModeChange($event, sys.config)">
|
||||
<option value="none">无</option>
|
||||
<option value="path">跟随路径</option>
|
||||
<option v-if="selectedSpineBone || sys.config.emitterFollowMode === 'bone'" value="bone">选中的骨骼</option>
|
||||
</select></label>
|
||||
<label v-if="sys.config.emitterFollowMode === 'path'" class="field-block"><span>跟随路径</span><select v-model.number="sys.config.emitterFollowPathId" class="inp">
|
||||
<option :value="0">无</option>
|
||||
<option v-for="path in store.paths" :key="path.id" :value="path.id">{{ path.name }}</option>
|
||||
</select></label>
|
||||
<label class="field-block"><span>发射器角度模式</span><select v-model="sys.config.emitterAngleMode" class="inp">
|
||||
<option value="fixed">固定角度</option>
|
||||
<option value="direction">跟随移动方向</option>
|
||||
</select></label>
|
||||
<div class="gravity-curve-title"><span>移动进度曲线 (0→1)</span></div>
|
||||
<CurveEditor v-model="sys.config.emitterFollowCurve" :default-value="LINEAR_CURVE" />
|
||||
<div class="field-grid follow-grid">
|
||||
<label class="compact-field"><span>移动时长 (s)</span><input v-model.number="sys.config.emitterFollowDuration" type="number" min="0.05" step="0.05" class="inp" /></label>
|
||||
<label class="compact-field"><span>跟随空间</span><select v-model="sys.config.emitterFollowSpace" class="inp">
|
||||
<option value="local">局部空间(GEN 移动)</option>
|
||||
<option value="world">世界空间(粒子生成点移动)</option>
|
||||
<template v-if="sys.config.emitterFollowMode === 'path'">
|
||||
<label class="field-block"><span>跟随路径</span><select v-model.number="sys.config.emitterFollowPathId" class="inp">
|
||||
<option :value="0">无</option>
|
||||
<option v-for="path in store.paths" :key="path.id" :value="path.id">{{ path.name }}</option>
|
||||
</select></label>
|
||||
</div>
|
||||
<label class="field-block"><span>移动方向</span><select v-model="sys.config.emitterFollowDirection" class="inp">
|
||||
<option value="forward">正向(起点 → 终点)</option>
|
||||
<option value="reverse">反向(终点 → 起点)</option>
|
||||
</select></label>
|
||||
<NumSlider label="偏移起始点 (0-1)" :min="0" :max="1" :step="0.01" v-model="sys.config.emitterFollowOffset" />
|
||||
<div class="modifier-hint">局部空间会移动整个发射器;世界空间只移动新粒子的生成点,已出生粒子留在世界位置。</div>
|
||||
<label class="field-block"><span>发射器角度模式</span><select v-model="sys.config.emitterAngleMode" class="inp">
|
||||
<option value="fixed">固定角度</option>
|
||||
<option value="direction">跟随移动方向</option>
|
||||
</select></label>
|
||||
<div class="gravity-curve-title"><span>移动进度曲线 (0→1)</span></div>
|
||||
<CurveEditor v-model="sys.config.emitterFollowCurve" :default-value="LINEAR_CURVE" />
|
||||
<div class="field-grid follow-grid">
|
||||
<label class="compact-field"><span>移动时长 (s)</span><input v-model.number="sys.config.emitterFollowDuration" type="number" min="0.05" step="0.05" class="inp" /></label>
|
||||
<label class="compact-field"><span>跟随空间</span><select v-model="sys.config.emitterFollowSpace" class="inp">
|
||||
<option value="local">局部空间(GEN 移动)</option>
|
||||
<option value="world">世界空间(粒子生成点移动)</option>
|
||||
</select></label>
|
||||
</div>
|
||||
<label class="field-block"><span>移动方向</span><select v-model="sys.config.emitterFollowDirection" class="inp">
|
||||
<option value="forward">正向(起点 → 终点)</option>
|
||||
<option value="reverse">反向(终点 → 起点)</option>
|
||||
</select></label>
|
||||
<NumSlider label="偏移起始点 (0-1)" :min="0" :max="1" :step="0.01" v-model="sys.config.emitterFollowOffset" />
|
||||
<div class="modifier-hint">局部空间会移动整个发射器;世界空间只移动新粒子的生成点,已出生粒子留在世界位置。</div>
|
||||
</template>
|
||||
<template v-else-if="sys.config.emitterFollowMode === 'bone'">
|
||||
<div v-if="boundSpineBone" class="bone-follow-target">跟随目标:<strong>{{ boundSpineBone.spine.name }} / {{ boundSpineBone.boneName }}</strong></div>
|
||||
<div v-else class="bone-follow-missing">尚未绑定有效骨骼,请先在 Spine 层级树中选择骨骼。</div>
|
||||
<button
|
||||
v-if="selectedSpineBone && (!boundSpineBone || selectedSpineBone.spine.id !== boundSpineBone.spine.id || selectedSpineBone.boneName !== boundSpineBone.boneName)"
|
||||
class="bone-follow-rebind"
|
||||
@click="bindSelectedBone(sys.config)"
|
||||
>{{ boundSpineBone ? '重新绑定' : '绑定' }}当前选中骨骼:{{ selectedSpineBone.spine.name }} / {{ selectedSpineBone.boneName }}</button>
|
||||
<template v-if="boundSpineBone">
|
||||
<label class="field-block"><span>跟随空间</span><select v-model="sys.config.emitterFollowSpace" class="inp">
|
||||
<option value="local">本地空间(随骨骼移动)</option>
|
||||
<option value="world">世界空间(粒子生成后保持位置)</option>
|
||||
</select></label>
|
||||
<div class="field-grid follow-grid">
|
||||
<label class="compact-field"><span>位移偏移 X</span><input v-model.number="sys.config.emitterFollowBoneOffsetX" type="number" step="1" class="inp" /></label>
|
||||
<label class="compact-field"><span>位移偏移 Y</span><input v-model.number="sys.config.emitterFollowBoneOffsetY" type="number" step="1" class="inp" /></label>
|
||||
</div>
|
||||
<div class="bone-follow-transform-grid">
|
||||
<label class="compact-field"><span>旋转偏移</span><input v-model.number="sys.config.emitterFollowBoneRotation" type="number" step="1" class="inp" /></label>
|
||||
<label class="compact-field"><span>缩放偏移 X</span><input v-model.number="sys.config.emitterFollowBoneScaleX" type="number" step="0.05" class="inp" /></label>
|
||||
<label class="compact-field"><span>缩放偏移 Y</span><input v-model.number="sys.config.emitterFollowBoneScaleY" type="number" step="0.05" class="inp" /></label>
|
||||
</div>
|
||||
<div class="modifier-hint">发射器只跟随所选 Spine 骨骼的位置,不继承 Spine 的旋转或缩放;粒子变换使用粒子系统自身参数和上方偏移。</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<NumSlider label="阻尼" :min="0" :max="2" :step="0.05" v-model="sys.config.damping" />
|
||||
@@ -757,6 +797,25 @@ const activePath = computed(() => store.activeObjectType === 'path'
|
||||
const activeSpine = computed(() => store.activeObjectType === 'spine'
|
||||
? store.spines.find((item) => item.id === store.activeObjectId) || null
|
||||
: null)
|
||||
const selectedSpineBone = computed(() => {
|
||||
const spine = store.spines.find((item) => item.selectedBone && item.bones.some((bone) => bone.name === item.selectedBone))
|
||||
return spine ? { spine, boneName: spine.selectedBone } : null
|
||||
})
|
||||
const boundSpineBone = computed(() => {
|
||||
const config = sys.value?.config
|
||||
if (!config || config.emitterFollowMode !== 'bone') return null
|
||||
const spine = store.spines.find((item) => item.id === config.emitterFollowSpineId)
|
||||
if (!spine || !spine.bones.some((bone) => bone.name === config.emitterFollowBoneName)) return null
|
||||
return { spine, boneName: config.emitterFollowBoneName }
|
||||
})
|
||||
|
||||
// 兼容开发热更新和旧场景:缺少显隐字段的对象按“显示”处理。
|
||||
watchEffect(() => {
|
||||
for (const system of store.systems) if (system.visible == null) system.visible = true
|
||||
for (const collider of store.colliders) if (collider.visible == null) collider.visible = true
|
||||
for (const path of store.paths) if (path.visible == null) path.visible = true
|
||||
for (const spine of store.spines) if (spine.visible == null) spine.visible = true
|
||||
})
|
||||
|
||||
// 开发热更新会保留旧的 Pinia 对象;渲染属性控件前先补齐新增字段。
|
||||
watchEffect(() => {
|
||||
@@ -770,6 +829,22 @@ watchEffect(() => {
|
||||
if (config.mode === 'stream' && (!(config.duration > 0))) config.duration = 1
|
||||
})
|
||||
|
||||
// 每个粒子系统永久保存自己的 Spine ID 与骨骼名;层级树当前选择不会覆盖既有绑定。
|
||||
// 仅当已绑定的 Spine 或骨骼确实不存在时,才回到“无”模式。
|
||||
watchEffect(() => {
|
||||
for (const system of store.systems) {
|
||||
const config = ensureEmitterConfig(system.config as any)
|
||||
if (config.emitterFollowMode !== 'bone') continue
|
||||
const spine = store.spines.find((item) => item.id === config.emitterFollowSpineId)
|
||||
const targetExists = !!spine?.bones.some((bone) => bone.name === config.emitterFollowBoneName)
|
||||
if (!targetExists) {
|
||||
config.emitterFollowMode = 'none'
|
||||
config.emitterFollowSpineId = 0
|
||||
config.emitterFollowBoneName = ''
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// HMR 或旧场景中的碰撞体缺少路径跟随字段时即时补齐。
|
||||
watchEffect(() => {
|
||||
for (const collider of store.colliders) {
|
||||
@@ -806,6 +881,38 @@ function setEmitMode(mode: 'stream' | 'burst') {
|
||||
if (mode === 'stream' && (!(config.duration > 0))) config.duration = 1
|
||||
}
|
||||
|
||||
function onEmitterFollowModeChange(event: Event, config: {
|
||||
emitterFollowMode: 'none' | 'path' | 'bone'
|
||||
emitterFollowSpineId: number
|
||||
emitterFollowBoneName: string
|
||||
}) {
|
||||
const mode = (event.target as HTMLSelectElement).value as 'none' | 'path' | 'bone'
|
||||
config.emitterFollowMode = mode
|
||||
if (mode !== 'bone') {
|
||||
config.emitterFollowSpineId = 0
|
||||
config.emitterFollowBoneName = ''
|
||||
return
|
||||
}
|
||||
bindSelectedBone(config)
|
||||
}
|
||||
|
||||
function bindSelectedBone(config: {
|
||||
emitterFollowMode: 'none' | 'path' | 'bone'
|
||||
emitterFollowSpineId: number
|
||||
emitterFollowBoneName: string
|
||||
}) {
|
||||
const target = selectedSpineBone.value
|
||||
if (!target) {
|
||||
config.emitterFollowMode = 'none'
|
||||
config.emitterFollowSpineId = 0
|
||||
config.emitterFollowBoneName = ''
|
||||
return
|
||||
}
|
||||
config.emitterFollowMode = 'bone'
|
||||
config.emitterFollowSpineId = target.spine.id
|
||||
config.emitterFollowBoneName = target.boneName
|
||||
}
|
||||
|
||||
type ResetModule = 'emitMode' | 'shape' | 'attr' | 'look' | 'mods' | 'export'
|
||||
|
||||
const MODULE_RESET_KEYS: Record<Exclude<ResetModule, 'export'>, string[]> = {
|
||||
@@ -843,7 +950,10 @@ const MODULE_RESET_KEYS: Record<Exclude<ResetModule, 'export'>, string[]> = {
|
||||
'trailBoneCount', 'trailLength', 'trailWidth', 'trailWidthMode',
|
||||
'trailLifeLengthEnabled', 'trailLifeLengthCurve', 'trailShapeEnabled', 'trailShapeCurve',
|
||||
'trailGridRows', 'trailGridCols', 'collision', 'pathFollow',
|
||||
'emitterFollow', 'emitterFollowMode', 'emitterFollowPathId', 'emitterAngleMode',
|
||||
'emitterFollow', 'emitterFollowMode', 'emitterFollowPathId',
|
||||
'emitterFollowSpineId', 'emitterFollowBoneName',
|
||||
'emitterFollowBoneOffsetX', 'emitterFollowBoneOffsetY', 'emitterFollowBoneRotation',
|
||||
'emitterFollowBoneScaleX', 'emitterFollowBoneScaleY', 'emitterAngleMode',
|
||||
'emitterFollowCurve', 'emitterFollowDuration', 'emitterFollowSpace',
|
||||
'emitterFollowDirection', 'emitterFollowOffset', 'damping',
|
||||
],
|
||||
@@ -1346,11 +1456,19 @@ function toggleResourceLock(resourceId: number) {
|
||||
.tree-item .tdot.collision-dot { color: #ff7070; }
|
||||
.tree-item .tdot.path-dot { color: #ff933e; font-size: 14px; }
|
||||
.tree-item .tdot.spine-dot { color: #c08cff; font-size: 11px; }
|
||||
.tree-item .tlabel { flex: 1; }
|
||||
.tree-item .tlabel { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.scene-tree { padding-bottom: 8px; border-bottom: 1px solid #2a2a3c; }
|
||||
.scene-object-item { min-height: 28px; cursor: pointer; }
|
||||
.scene-object-item:hover { background: #202b40; }
|
||||
.scene-object-kind { color: #75829c; font-size: 10px; }
|
||||
.scene-object-eye {
|
||||
width: 24px; height: 22px; padding: 3px; flex: 0 0 24px; border: 1px solid transparent; border-radius: 5px;
|
||||
background: transparent; color: #9aa6bf; cursor: pointer;
|
||||
}
|
||||
.scene-object-eye:hover { border-color: #4b5972; background: #29354a; color: #e1e7f4; }
|
||||
.scene-object-eye.off { color: #59647a; }
|
||||
.scene-object-eye svg { display: block; width: 100%; height: 100%; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.scene-object-eye .eye-slash { stroke: #d47b88; stroke-width: 2; }
|
||||
.scene-object-delete {
|
||||
width: 24px; height: 22px; padding: 0; border: 1px solid #59414a; border-radius: 5px;
|
||||
background: #3a272f; color: #efb4bd; font-size: 11px; cursor: pointer;
|
||||
@@ -1470,6 +1588,12 @@ function toggleResourceLock(resourceId: number) {
|
||||
.modifier-switch { font-weight: 600; }
|
||||
.modifier-params { margin: 8px 0 0 10px; padding: 2px 0 2px 10px; border-left: 2px solid #344158; }
|
||||
.modifier-hint { margin: 7px 0 0 2px; color: #647086; font-size: 9px; line-height: 1.5; }
|
||||
.bone-follow-target { margin: 7px 0 9px; padding: 7px 8px; overflow: hidden; color: #7f8ba3; background: #121a29; border: 1px solid #2f3c53; border-radius: 5px; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bone-follow-target strong { color: #c8d0e4; font-weight: 600; }
|
||||
.bone-follow-missing { margin: 7px 0 9px; padding: 7px 8px; color: #d49aa3; background: #2a1e26; border: 1px solid #513540; border-radius: 5px; font-size: 10px; line-height: 1.5; }
|
||||
.bone-follow-rebind { width: 100%; min-height: 28px; margin: 0 0 8px; padding: 4px 8px; overflow: hidden; border: 1px solid #4a436e; border-radius: 5px; background: #2d2947; color: #c8c2ee; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }
|
||||
.bone-follow-rebind:hover { border-color: #7165bd; background: #3a335d; color: #fff; }
|
||||
.bone-follow-transform-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; }
|
||||
.modifier-mode-row { display: flex; justify-content: flex-end; gap: 4px; margin-bottom: 6px; }
|
||||
.gravity-curve-title { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 10px 0 6px; color: #9ba7bd; font-size: 11px; }
|
||||
.gravity-curve-title span:last-child { color: #657188; font-size: 9px; }
|
||||
|
||||
+120
-23
@@ -34,9 +34,6 @@
|
||||
<label class="sp-chk"><input type="checkbox" v-model="showEmitter" />发射点</label>
|
||||
<label class="sp-chk"><input type="checkbox" v-model="showBones" />骨骼连线</label>
|
||||
<label class="sp-chk"><input type="checkbox" v-model="showSkinMesh" />蒙皮网格</label>
|
||||
<label class="sp-chk" :class="{ disabled: !hasLoadedSpineAnimation }" :title="hasLoadedSpineAnimation ? '显示或隐藏已加载的 Spine 动画资源' : '加载包含动画的 Spine 资源后可用'">
|
||||
<input type="checkbox" v-model="store.settings.showSpineResources" :disabled="!hasLoadedSpineAnimation" />启用 Spine 资源显示
|
||||
</label>
|
||||
<div class="sp-divider"></div>
|
||||
<label class="sp-chk"><input type="checkbox" v-model="store.settings.tickEnabled" />启用刻度</label>
|
||||
<template v-if="store.settings.tickEnabled">
|
||||
@@ -57,7 +54,7 @@
|
||||
</div>
|
||||
|
||||
<div class="hud" v-if="info">
|
||||
系统: <b>{{ info.systems }}</b> · 粒子(骨骼): <b>{{ info.particles }}</b> · 根骨骼: <b>root</b>
|
||||
系统: <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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -78,8 +75,6 @@ const showSkinMesh = ref(false)
|
||||
const info = ref<{ systems: number; particles: number } | null>(null)
|
||||
const showGrid = ref(true)
|
||||
const showSettings = ref(false)
|
||||
if (store.settings.showSpineResources == null) store.settings.showSpineResources = true
|
||||
const hasLoadedSpineAnimation = computed(() => store.spines.some((spine) => spine.status === 'ready' && spine.animations.length > 0))
|
||||
// 根变换 gizmo 工具:'translate' | 'rotate' | 'scale' | ''(无)
|
||||
const activeTool = ref<'' | 'translate' | 'rotate' | 'scale'>('')
|
||||
function setTool(t: '' | 'translate' | 'rotate' | 'scale') { activeTool.value = activeTool.value === t ? '' : t }
|
||||
@@ -87,6 +82,7 @@ function setTool(t: '' | 'translate' | 'rotate' | 'scale') { activeTool.value =
|
||||
let app: Application | null = null
|
||||
let world: Container | null = null
|
||||
let originGfx: Graphics | null = null
|
||||
let boneFollowTargetGfx: Graphics | null = null
|
||||
let boneGfx: Graphics | null = null
|
||||
let shapeGfx: Graphics | null = null
|
||||
let forceFieldGfx: Graphics | null = null
|
||||
@@ -132,6 +128,8 @@ function createLayers() {
|
||||
world.addChild(axisGfx)
|
||||
spineRuntime = new SpineRuntimeLayer()
|
||||
world.addChild(spineRuntime.container)
|
||||
boneFollowTargetGfx = new Graphics()
|
||||
world.addChild(boneFollowTargetGfx)
|
||||
originGfx = new Graphics()
|
||||
world.addChild(originGfx)
|
||||
shapeGfx = new Graphics() // 发射器形状范围(绿圆/蓝矩形/锥形)
|
||||
@@ -323,7 +321,8 @@ function worldToPathLocal(path: ScenePath, x: number, y: number) {
|
||||
|
||||
function activePath() {
|
||||
if (store.activeObjectType !== 'path') return null
|
||||
return store.paths.find((item) => item.id === store.activeObjectId) || null
|
||||
const path = store.paths.find((item) => item.id === store.activeObjectId) || null
|
||||
return path?.visible === false ? null : path
|
||||
}
|
||||
|
||||
function pathInsertIndex(path: ScenePath, x: number, y: number) {
|
||||
@@ -468,7 +467,7 @@ function installPointerDrag(move: (event: PointerEvent) => void) {
|
||||
function onColliderPointerDown(e: PointerEvent) {
|
||||
if (store.activeObjectType !== 'collision') return false
|
||||
const collider = store.colliders.find((item) => item.id === store.activeObjectId)
|
||||
if (!collider?.enabled) return false
|
||||
if (!collider?.enabled || collider.visible === false) return false
|
||||
const pointer = pointerToWorldMath(e.clientX, e.clientY)
|
||||
const angle = collider.rotation * Math.PI / 180
|
||||
const cos = Math.cos(angle), sin = Math.sin(angle)
|
||||
@@ -494,6 +493,7 @@ function onColliderPointerDown(e: PointerEvent) {
|
||||
/** 命中当前选中系统的力场圆时开始拖拽;返回 true 表示事件已被力场接管。 */
|
||||
function onForceFieldDown(e: PointerEvent) {
|
||||
const sys = store.systems.find((item) => item.id === store.activeId)
|
||||
if (sys?.visible === false) return false
|
||||
const cfg = sys?.config
|
||||
if (!cfg?.forceField || cfg.forceRadius <= 0) return false
|
||||
const startPointer = pointerToWorldMath(e.clientX, e.clientY)
|
||||
@@ -523,6 +523,7 @@ function onForceFieldDown(e: PointerEvent) {
|
||||
/** 命中吸附圆(半径为 0 时使用屏幕 12px 热区)时拖动全局吸附点。 */
|
||||
function onAttractionDown(e: PointerEvent) {
|
||||
const sys = store.systems.find((item) => item.id === store.activeId)
|
||||
if (sys?.visible === false) return false
|
||||
const cfg = sys?.config
|
||||
if (!cfg?.attraction) return false
|
||||
const startPointer = pointerToWorldMath(e.clientX, e.clientY)
|
||||
@@ -565,7 +566,7 @@ function selectedTransformTarget(): SceneTransformTarget | null {
|
||||
const id = store.activeObjectId
|
||||
if (store.activeObjectType === 'particle') {
|
||||
const system = store.systems.find((item) => item.id === id)
|
||||
if (!system) return null
|
||||
if (!system || system.visible === false) return null
|
||||
const config = system.config
|
||||
return {
|
||||
x: config.centerX, y: config.centerY, rotation: config.rootRotation,
|
||||
@@ -581,7 +582,7 @@ function selectedTransformTarget(): SceneTransformTarget | null {
|
||||
? store.paths
|
||||
: store.spines
|
||||
const object = collection.find((item) => item.id === id) as any
|
||||
if (!object) return null
|
||||
if (!object || object.visible === false) return null
|
||||
return {
|
||||
x: Number(object.x) || 0, y: Number(object.y) || 0, rotation: Number(object.rotation) || 0,
|
||||
scaleX: Math.max(0.05, Number(object.scaleX) || 1), scaleY: Math.max(0.05, Number(object.scaleY) || 1),
|
||||
@@ -677,6 +678,7 @@ function drawOrigin() {
|
||||
// 每个系统对应一个发射点(坐标点);没有系统时不画
|
||||
if (!store.systems.length) return
|
||||
for (const sys of store.systems) {
|
||||
if (sys.visible === false) continue
|
||||
const preview = emitterPreviewTransform.get(sys.id)
|
||||
const cx = preview?.x ?? sys.config.centerX ?? 0
|
||||
const cy = -(preview?.y ?? sys.config.centerY ?? 0)
|
||||
@@ -694,6 +696,44 @@ function drawOrigin() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 标出层级树当前选中的骨骼;发射器骨骼跟随复用同一目标点。 */
|
||||
function drawBoneFollowTargets() {
|
||||
if (!boneFollowTargetGfx) return
|
||||
const g = boneFollowTargetGfx
|
||||
g.parent?.addChild(g)
|
||||
g.clear()
|
||||
const targets = new Map<string, { spineId: number; boneName: string }>()
|
||||
for (const spine of store.spines) {
|
||||
if (spine.visible === false || !spine.selectedBone || spine.status !== 'ready') continue
|
||||
targets.set(`${spine.id}:${spine.selectedBone}`, { spineId: spine.id, boneName: spine.selectedBone })
|
||||
}
|
||||
for (const system of store.systems) {
|
||||
if (system.visible === false) continue
|
||||
const config = system.config
|
||||
if (!config.emitterFollow || config.emitterFollowMode !== 'bone') continue
|
||||
if (!config.emitterFollowSpineId || !config.emitterFollowBoneName) continue
|
||||
const spine = store.spines.find((item) => item.id === config.emitterFollowSpineId)
|
||||
if (!spine || spine.visible === false) continue
|
||||
targets.set(`${config.emitterFollowSpineId}:${config.emitterFollowBoneName}`, {
|
||||
spineId: config.emitterFollowSpineId,
|
||||
boneName: config.emitterFollowBoneName,
|
||||
})
|
||||
}
|
||||
for (const item of targets.values()) {
|
||||
const target = spineRuntime?.getBoneWorldTransform(item.spineId, item.boneName)
|
||||
if (!target) continue
|
||||
const x = target.x
|
||||
const y = -target.y
|
||||
g.lineStyle(2, 0xc48cff, 0.95)
|
||||
g.beginFill(0xc48cff, 0.2)
|
||||
g.drawCircle(x, y, 8)
|
||||
g.endFill()
|
||||
g.beginFill(0xf2e7ff, 1)
|
||||
g.drawCircle(x, y, 3.5)
|
||||
g.endFill()
|
||||
}
|
||||
}
|
||||
|
||||
// 绘制当前选中系统的发射器形状范围(点/轨道不绘制),随参数实时响应
|
||||
function drawShapeRange() {
|
||||
if (!shapeGfx) return
|
||||
@@ -701,7 +741,7 @@ function drawShapeRange() {
|
||||
g.clear()
|
||||
if (!showEmitter.value) return
|
||||
const sys = store.systems.find((s) => s.id === store.activeId)
|
||||
if (!sys) return
|
||||
if (!sys || sys.visible === false) return
|
||||
const c = sys.config
|
||||
const preview = emitterPreviewTransform.get(sys.id)
|
||||
// root 中心(发射器容器 position = centerX/-centerY),数学坐标
|
||||
@@ -761,6 +801,7 @@ function drawForceField() {
|
||||
const g = forceFieldGfx
|
||||
g.clear()
|
||||
const sys = store.systems.find((item) => item.id === store.activeId)
|
||||
if (sys?.visible === false) return
|
||||
const cfg = sys?.config
|
||||
if (!cfg?.forceField || cfg.forceRadius <= 0) return
|
||||
const x = cfg.forceCenterX
|
||||
@@ -784,6 +825,7 @@ function drawAttraction() {
|
||||
const g = attractionGfx
|
||||
g.clear()
|
||||
const sys = store.systems.find((item) => item.id === store.activeId)
|
||||
if (sys?.visible === false) return
|
||||
const cfg = sys?.config
|
||||
if (!cfg?.attraction) return
|
||||
const x = cfg.attractionCenterX
|
||||
@@ -908,6 +950,7 @@ function drawCollisionBodies() {
|
||||
const g = collisionGfx
|
||||
g.clear()
|
||||
for (const collider of previewColliders) {
|
||||
if (collider.visible === false) continue
|
||||
const selected = store.activeObjectType === 'collision' && store.activeObjectId === collider.id
|
||||
const color = collider.enabled ? (selected ? 0xff5e72 : 0xd4475b) : 0x586174
|
||||
const angle = collider.rotation * Math.PI / 180
|
||||
@@ -954,7 +997,7 @@ function drawPaths() {
|
||||
const g = pathGfx
|
||||
g.clear()
|
||||
for (const path of store.paths) {
|
||||
if (!path.enabled || !path.points.length) continue
|
||||
if (path.visible === false || !path.enabled || !path.points.length) continue
|
||||
const selected = store.activeObjectType === 'path' && store.activeObjectId === path.id
|
||||
const color = colorNumber(path.color, 0xff933e)
|
||||
const screenPoint = (x: number, y: number) => {
|
||||
@@ -1072,9 +1115,13 @@ function buildPreviewColliders(timeSeconds: number) {
|
||||
})
|
||||
}
|
||||
|
||||
function worldPointToEmitterLocal(config: { centerX: number; centerY: number; rootRotation: number; rootScaleX: number; rootScaleY: number }, worldX: number, worldY: number) {
|
||||
const scaleX = Math.max(0.0001, Math.abs(config.rootScaleX))
|
||||
const scaleY = Math.max(0.0001, Math.abs(config.rootScaleY))
|
||||
function worldPointToEmitterLocal(
|
||||
config: { centerX: number; centerY: number; rootRotation: number; rootScaleX: number; rootScaleY: number },
|
||||
worldX: number,
|
||||
worldY: number,
|
||||
scaleX = Math.max(0.0001, Math.abs(config.rootScaleX)),
|
||||
scaleY = Math.max(0.0001, Math.abs(config.rootScaleY)),
|
||||
) {
|
||||
const angle = -config.rootRotation * Math.PI / 180
|
||||
const cos = Math.cos(angle), sin = Math.sin(angle)
|
||||
const screenX = worldX - config.centerX
|
||||
@@ -1096,16 +1143,29 @@ function loop() {
|
||||
drawAxis()
|
||||
syncEmitters()
|
||||
// 根节点(root)变换:位置(centerX/centerY)=位移,rotation/scale 绕 root 中心,与场景对象参数联动
|
||||
const followTime = store.timeline.frame / Math.max(1, store.timeline.fps)
|
||||
const followFps = Math.max(1, store.timeline.fps)
|
||||
const followFrame = store.timeline.frame
|
||||
const followTime = followFrame / followFps
|
||||
// 发射器读取骨骼变换前,先把 Spine 运行时同步到同一时间轴帧。
|
||||
spineRuntime?.sync(store.spines, followFrame, followFps)
|
||||
buildPreviewColliders(followTime)
|
||||
for (const sys of store.systems) {
|
||||
const em = emitterMap.get(sys.id)
|
||||
if (!em) continue
|
||||
const visible = sys.visible !== false
|
||||
em.visible = visible
|
||||
em.renderable = visible
|
||||
const c = sys.config
|
||||
em.scale.set(c.rootScaleX, c.rootScaleY)
|
||||
em.setColliders(previewColliders)
|
||||
em.setEmitterFollowSpawn(0, 0, 0)
|
||||
let preview = { x: c.centerX, y: c.centerY, rotation: c.rootRotation }
|
||||
const followSpine = c.emitterFollow && c.emitterFollowMode === 'bone'
|
||||
? store.spines.find((spine) => spine.id === c.emitterFollowSpineId && spine.enabled && spine.status === 'ready')
|
||||
: null
|
||||
const boneTransform = followSpine
|
||||
? spineRuntime?.getBoneWorldTransform(followSpine.id, c.emitterFollowBoneName) || null
|
||||
: null
|
||||
const followPath = c.emitterFollow && c.emitterFollowMode === 'path'
|
||||
? store.paths.find((path) => path.id === c.emitterFollowPathId && path.enabled)
|
||||
: null
|
||||
@@ -1117,7 +1177,33 @@ function loop() {
|
||||
? (1 - offset) * (1 - eased)
|
||||
: offset + (1 - offset) * eased
|
||||
const pathSample = followPath ? sampleScenePath(followPath, pathProgress) : null
|
||||
if (pathSample) {
|
||||
if (boneTransform) {
|
||||
// 骨骼只提供位置;旋转和缩放不传递给粒子,偏移使用世界坐标单位。
|
||||
const offsetX = c.emitterFollowBoneOffsetX
|
||||
const offsetY = c.emitterFollowBoneOffsetY
|
||||
const targetX = boneTransform.x + offsetX
|
||||
const targetY = boneTransform.y + offsetY
|
||||
const targetRotation = c.rootRotation + c.emitterFollowBoneRotation
|
||||
const targetScaleX = Math.max(0.0001, Math.abs(c.rootScaleX * c.emitterFollowBoneScaleX))
|
||||
const targetScaleY = Math.max(0.0001, Math.abs(c.rootScaleY * c.emitterFollowBoneScaleY))
|
||||
preview = { x: targetX, y: targetY, rotation: targetRotation }
|
||||
if (c.emitterFollowSpace === 'local') {
|
||||
// 本地空间:整个发射器容器随骨骼移动,已出生粒子也继承后续骨骼变换。
|
||||
em.position.set(targetX, -targetY)
|
||||
em.rotation = -targetRotation * Math.PI / 180
|
||||
em.scale.set(targetScaleX, targetScaleY)
|
||||
em.setSimulationTransform(targetX, targetY, targetRotation, targetScaleX, targetScaleY)
|
||||
} else {
|
||||
// 世界空间:保持粒子系统根容器不动,只把新粒子的出生点和方向移动到骨骼。
|
||||
em.position.set(c.centerX, -c.centerY)
|
||||
em.rotation = -c.rootRotation * Math.PI / 180
|
||||
em.scale.set(targetScaleX, targetScaleY)
|
||||
em.setSimulationTransform(c.centerX, c.centerY, c.rootRotation, targetScaleX, targetScaleY)
|
||||
const local = worldPointToEmitterLocal(c, targetX, targetY, targetScaleX, targetScaleY)
|
||||
const relativeDirection = -(targetRotation - c.rootRotation)
|
||||
em.setEmitterFollowSpawn(local.x, local.y, relativeDirection)
|
||||
}
|
||||
} else if (pathSample) {
|
||||
const movementAngle = pathSample.angle + (c.emitterFollowDirection === 'reverse' ? 180 : 0)
|
||||
const effectiveAngle = c.emitterAngleMode === 'direction' ? movementAngle : c.rootRotation
|
||||
preview = { x: pathSample.x, y: pathSample.y, rotation: effectiveAngle }
|
||||
@@ -1166,9 +1252,10 @@ function loop() {
|
||||
if (!tl.recorded) {
|
||||
// 录制阶段:固定步长模拟并记录当前帧。
|
||||
if (tl.frame === 0) { for (const [, em] of emitterMap) em.reset() }
|
||||
for (const [, em] of emitterMap) {
|
||||
for (const [systemId, em] of emitterMap) {
|
||||
const states = em.update(frameDuration)
|
||||
for (const state of states) if (state.active) appendStateBones(collected, state)
|
||||
const systemVisible = store.systems.find((item) => item.id === systemId)?.visible !== false
|
||||
if (systemVisible) for (const state of states) if (state.active) appendStateBones(collected, state)
|
||||
total += em.activeCount
|
||||
}
|
||||
for (const sys of store.systems) {
|
||||
@@ -1186,7 +1273,10 @@ function loop() {
|
||||
const frame = sys.frames?.[tl.frame]
|
||||
if (emitter && frame) {
|
||||
emitter.apply(frame)
|
||||
for (const state of frame) if (state.active) { appendStateBones(collected, state); total++ }
|
||||
for (const state of frame) if (state.active) {
|
||||
if (sys.visible !== false) appendStateBones(collected, state)
|
||||
total++
|
||||
}
|
||||
}
|
||||
}
|
||||
tl.frame++
|
||||
@@ -1205,19 +1295,23 @@ function loop() {
|
||||
if (!tl.playing && tl.recorded && emitter && frame) {
|
||||
// 暂停、逐帧或手动拖动播放头时立即显示指定的录制帧。
|
||||
emitter.apply(frame)
|
||||
for (const state of frame) if (state.active) { appendStateBones(collected, state); total++ }
|
||||
for (const state of frame) if (state.active) {
|
||||
if (sys.visible !== false) appendStateBones(collected, state)
|
||||
total++
|
||||
}
|
||||
} else if (emitter) {
|
||||
const states = emitter.update(0)
|
||||
for (const state of states) if (state.active) appendStateBones(collected, state)
|
||||
if (sys.visible !== false) for (const state of states) if (state.active) appendStateBones(collected, state)
|
||||
total += emitter.activeCount
|
||||
}
|
||||
}
|
||||
}
|
||||
timelineDisplayFrame = displayFrame
|
||||
spineRuntime?.sync(store.spines, displayFrame, fps, store.settings.showSpineResources)
|
||||
spineRuntime?.sync(store.spines, displayFrame, fps)
|
||||
for (const [, emitter] of emitterMap) emitter.drawTrailMeshDebug(showSkinMesh.value)
|
||||
drawCollisionBodies()
|
||||
drawPaths()
|
||||
drawBoneFollowTargets()
|
||||
drawOrigin()
|
||||
drawShapeRange()
|
||||
drawForceField()
|
||||
@@ -1315,6 +1409,9 @@ onUnmounted(() => {
|
||||
.tool-btn.on { background: #3a5a8c; color: #fff; border-color: #5a7ab8; }
|
||||
.zoom-pct { margin-left: auto; font-variant-numeric: tabular-nums; color: #9aa; min-width: 44px; text-align: center; }
|
||||
.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; }
|
||||
.hud-particle-count { width: 7ch; text-align: left; }
|
||||
.hud-tip { margin-left: 12px; color: #5f6a8a; font-size: 11px; }
|
||||
b { color: #ffcc33; }
|
||||
.settings-btn {
|
||||
|
||||
+150
-6
@@ -10,11 +10,47 @@
|
||||
<button class="tl-btn" @click="store.nextFrame()" title="后一帧">▶</button>
|
||||
<button class="tl-btn" @click="store.toEnd()" title="移动到最后一帧">⏭</button>
|
||||
<button class="tl-btn" :class="{ on: store.timeline.loop }" @click="store.toggleLoop()" title="循环">⟳</button>
|
||||
<span class="tl-frame">帧 {{ store.timeline.frame }} / {{ store.timeline.totalFrames }}</span>
|
||||
<span class="tl-frame">帧 <b>{{ store.timeline.frame }}</b> / <b>{{ store.timeline.totalFrames }}</b></span>
|
||||
<span class="tl-legend"><i class="legend-emission"></i>持续发射 <i class="legend-life"></i>粒子存在 <i class="legend-spine"></i>Spine 动画</span>
|
||||
<label class="tl-anim">动画
|
||||
<select v-model="store.timeline.animation" class="tl-sel">
|
||||
<option value="animation">animation</option>
|
||||
<div ref="animationMenuRef" class="tl-anim tl-owner-animation">
|
||||
<span>所属动画</span>
|
||||
<button class="tl-animation-trigger" :disabled="!activeParticleSystem" title="设置当前粒子系统所属的动画" @click.stop="toggleAnimationMenu">
|
||||
<span>{{ activeParticleSystem?.animation || store.timeline.animation }}</span><i>⌄</i>
|
||||
</button>
|
||||
<div v-if="animationMenuOpen" class="tl-animation-menu" @pointerdown.stop>
|
||||
<div class="tl-animation-menu-title">选择所属动画</div>
|
||||
<button
|
||||
v-for="animation in store.timeline.animations"
|
||||
:key="animation"
|
||||
class="tl-animation-option"
|
||||
:class="{ on: activeParticleSystem?.animation === animation }"
|
||||
@click="selectOwnerAnimation(animation)"
|
||||
>
|
||||
<span>{{ animation }}</span><i v-if="activeParticleSystem?.animation === animation">✓</i>
|
||||
</button>
|
||||
<div class="tl-animation-editor">
|
||||
<input v-model="animationNameDraft" class="tl-animation-name" maxlength="64" aria-label="动画名称" @keydown.enter="renameCurrentAnimation" />
|
||||
<button title="重命名当前动画" @click="renameCurrentAnimation">重命名</button>
|
||||
</div>
|
||||
<div v-if="animationError" class="tl-animation-error">{{ animationError }}</div>
|
||||
<div class="tl-animation-actions">
|
||||
<button @click="addAnimation">+ 新增</button>
|
||||
<button :disabled="store.timeline.animations.length <= 1" @click="deleteCurrentAnimation">删除</button>
|
||||
</div>
|
||||
<div v-if="store.timeline.animations.length <= 1" class="tl-animation-hint">至少需要保留一个动画</div>
|
||||
</div>
|
||||
</div>
|
||||
<label class="tl-anim tl-spine-preview">Spine 预览动画
|
||||
<select
|
||||
class="tl-sel"
|
||||
:value="previewSpine?.selectedAnimation || ''"
|
||||
:disabled="!previewSpine"
|
||||
@change="onSpinePreviewAnimationChange"
|
||||
>
|
||||
<option value="">{{ previewSpine ? '无' : '无可用 Spine 动画' }}</option>
|
||||
<option v-for="animation in previewSpine?.animations || []" :key="animation.name" :value="animation.name">
|
||||
{{ animation.name }}({{ animation.duration.toFixed(2) }}s)
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<span class="tl-zoom">{{ zoomLabel }}</span>
|
||||
@@ -82,7 +118,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watchEffect } from 'vue'
|
||||
import { useParticleStore } from '../store/particleStore'
|
||||
import type { SpineSceneObject } from '../spine/spineTypes'
|
||||
|
||||
@@ -90,6 +126,67 @@ type SysLike = { id: number; config: any }
|
||||
|
||||
const store = useParticleStore()
|
||||
const vpRef = ref<HTMLElement | null>(null)
|
||||
const animationMenuRef = ref<HTMLElement | null>(null)
|
||||
const animationMenuOpen = ref(false)
|
||||
const animationNameDraft = ref('animation')
|
||||
const animationError = ref('')
|
||||
|
||||
// 兼容旧场景与开发热更新:补齐动画列表和粒子系统归属。
|
||||
watchEffect(() => {
|
||||
if (!Array.isArray(store.timeline.animations) || !store.timeline.animations.length) store.timeline.animations = ['animation']
|
||||
if (!store.timeline.animations.includes(store.timeline.animation)) store.timeline.animation = store.timeline.animations[0]
|
||||
for (const system of store.systems) {
|
||||
if (!system.animation || !store.timeline.animations.includes(system.animation)) system.animation = store.timeline.animations[0]
|
||||
}
|
||||
})
|
||||
|
||||
const activeParticleSystem = computed(() => store.systems.find((system) => system.id === store.activeId) || null)
|
||||
|
||||
function toggleAnimationMenu() {
|
||||
if (!activeParticleSystem.value) return
|
||||
animationMenuOpen.value = !animationMenuOpen.value
|
||||
animationNameDraft.value = activeParticleSystem.value.animation
|
||||
animationError.value = ''
|
||||
}
|
||||
|
||||
function selectOwnerAnimation(animation: string) {
|
||||
const system = activeParticleSystem.value
|
||||
if (!system) return
|
||||
store.setSystemAnimation(system.id, animation)
|
||||
animationNameDraft.value = animation
|
||||
animationError.value = ''
|
||||
}
|
||||
|
||||
function addAnimation() {
|
||||
const name = store.addAnimation()
|
||||
animationNameDraft.value = name
|
||||
animationError.value = ''
|
||||
}
|
||||
|
||||
function renameCurrentAnimation() {
|
||||
const current = activeParticleSystem.value?.animation || store.timeline.animation
|
||||
const next = animationNameDraft.value.trim()
|
||||
if (!next) { animationError.value = '动画名称不能为空'; return }
|
||||
if (next !== current && store.timeline.animations.includes(next)) { animationError.value = '动画名称不能重复'; return }
|
||||
if (!store.renameAnimation(current, next)) { animationError.value = '无法修改动画名称'; return }
|
||||
animationNameDraft.value = next
|
||||
animationError.value = ''
|
||||
}
|
||||
|
||||
function deleteCurrentAnimation() {
|
||||
const current = activeParticleSystem.value?.animation || store.timeline.animation
|
||||
if (!store.removeAnimation(current)) { animationError.value = '至少需要保留一个动画'; return }
|
||||
const next = activeParticleSystem.value?.animation || store.timeline.animation
|
||||
animationNameDraft.value = next
|
||||
animationError.value = ''
|
||||
}
|
||||
|
||||
function onDocumentPointerDown(event: PointerEvent) {
|
||||
if (!animationMenuRef.value?.contains(event.target as Node)) animationMenuOpen.value = false
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('pointerdown', onDocumentPointerDown))
|
||||
onBeforeUnmount(() => document.removeEventListener('pointerdown', onDocumentPointerDown))
|
||||
|
||||
// 时间轴与固定步长模拟统一读取 Store;默认 1 秒 = 30 帧,与 Spine 对齐。
|
||||
const fps = computed(() => Math.max(1, store.timeline.fps || 30))
|
||||
@@ -106,6 +203,20 @@ const zoomLabel = computed(() => (viewFrames.value <= 30 ? '放大' : viewFrames
|
||||
const spineLanes = computed(() => store.spines.filter((spine) =>
|
||||
spine.selectedAnimation && spine.animations.some((animation) => animation.name === spine.selectedAnimation && animation.duration > 0),
|
||||
))
|
||||
const previewSpine = computed(() => {
|
||||
const active = store.activeObjectType === 'spine'
|
||||
? store.spines.find((spine) => spine.id === store.activeObjectId && spine.animations.length)
|
||||
: null
|
||||
return active || store.spines.find((spine) => spine.animations.length) || null
|
||||
})
|
||||
|
||||
function onSpinePreviewAnimationChange(event: Event) {
|
||||
const spine = previewSpine.value
|
||||
if (!spine) return
|
||||
spine.selectedAnimation = (event.target as HTMLSelectElement).value
|
||||
store.recalcTotalFrames()
|
||||
store.timeline.frame = 0
|
||||
}
|
||||
|
||||
// 每像素代表的帧数(视口宽度内放下 viewFrames 帧)
|
||||
function pxPerF() {
|
||||
@@ -286,14 +397,47 @@ function onSpineBarDown(e: PointerEvent, spine: SpineSceneObject) {
|
||||
.tl-controls { display: flex; align-items: center; gap: 8px; }
|
||||
.tl-btn { width: 30px; height: 26px; background: #1e1e2e; border: 1px solid #2e2e44; border-radius: 6px; color: #aab; font-size: 12px; cursor: pointer; }
|
||||
.tl-btn.on { background: #3a5a8c; color: #fff; border-color: #5a7ab8; }
|
||||
.tl-frame { font-size: 12px; color: #aab; }
|
||||
.tl-frame { display: inline-block; flex: 0 0 92px; width: 92px; box-sizing: border-box; font-size: 12px; color: #aab; text-align: right; font-variant-numeric: tabular-nums; font-feature-settings: 'tnum' 1; white-space: nowrap; }
|
||||
.tl-frame b { display: inline; color: inherit; font-weight: 400; }
|
||||
.tl-legend { display: flex; align-items: center; gap: 4px; color: #778198; font-size: 10px; }
|
||||
.tl-legend i { display: inline-block; width: 10px; height: 7px; border-radius: 2px; }
|
||||
.legend-emission { margin-left: 4px; background: #2f8b62; }
|
||||
.legend-life { margin-left: 5px; background: #a54551; }
|
||||
.legend-spine { margin-left: 5px; background: #7448b8; }
|
||||
.tl-anim { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #aab; }
|
||||
.tl-owner-animation { position: relative; }
|
||||
.tl-animation-trigger {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 8px; width: 116px; height: 25px; padding: 0 7px;
|
||||
overflow: hidden; border: 1px solid #2e2e44; border-radius: 5px; background: #1a1a28; color: #dde; cursor: pointer;
|
||||
}
|
||||
.tl-animation-trigger > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tl-animation-trigger > i { flex: 0 0 auto; color: #8c96ad; font-style: normal; }
|
||||
.tl-animation-trigger:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.tl-animation-menu {
|
||||
position: absolute; z-index: 30; top: calc(100% + 6px); right: 0; width: 226px; padding: 7px;
|
||||
border: 1px solid #3c4760; border-radius: 8px; background: #171b29; box-shadow: 0 10px 24px rgba(0,0,0,.45);
|
||||
}
|
||||
.tl-animation-menu-title { padding: 2px 4px 6px; color: #77839c; font-size: 10px; }
|
||||
.tl-animation-option {
|
||||
display: flex; align-items: center; justify-content: space-between; width: 100%; height: 27px; padding: 0 7px;
|
||||
border: 0; border-radius: 5px; background: transparent; color: #b8c1d5; text-align: left; cursor: pointer;
|
||||
}
|
||||
.tl-animation-option > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tl-animation-option:hover { background: #252e42; }
|
||||
.tl-animation-option.on { background: #34335d; color: #fff; }
|
||||
.tl-animation-option > i { color: #8d8aff; font-style: normal; }
|
||||
.tl-animation-editor { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 5px; margin-top: 7px; padding-top: 7px; border-top: 1px solid #2b3346; }
|
||||
.tl-animation-name { min-width: 0; height: 27px; padding: 0 7px; box-sizing: border-box; border: 1px solid #343e55; border-radius: 5px; background: #101522; color: #e1e6f2; outline: none; }
|
||||
.tl-animation-name:focus { border-color: #6865d8; }
|
||||
.tl-animation-editor button, .tl-animation-actions button { height: 27px; padding: 0 8px; border: 1px solid #3c4861; border-radius: 5px; background: #29344a; color: #cbd3e3; cursor: pointer; }
|
||||
.tl-animation-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; margin-top: 6px; }
|
||||
.tl-animation-actions button:last-child { border-color: #5b3942; background: #3b282f; color: #e2a8b2; }
|
||||
.tl-animation-actions button:disabled { opacity: .4; cursor: not-allowed; }
|
||||
.tl-animation-error { margin-top: 5px; color: #ef8997; font-size: 10px; }
|
||||
.tl-animation-hint { margin-top: 5px; color: #69758d; font-size: 9px; text-align: center; }
|
||||
.tl-sel { background: #1a1a28; border: 1px solid #2e2e44; color: #dde; border-radius: 5px; padding: 2px 6px; font-size: 12px; }
|
||||
.tl-sel:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.tl-spine-preview .tl-sel { max-width: 190px; }
|
||||
.tl-zoom { margin-left: auto; font-size: 11px; color: #667; }
|
||||
.tl-viewport { position: relative; flex: 1; overflow: hidden; background: #0d0d14; border: 1px solid #2a2a3c; border-radius: 6px; cursor: ew-resize; }
|
||||
.tl-ticks { position: absolute; top: 0; left: 0; right: 0; height: 18px; }
|
||||
|
||||
Reference in new issue
Block a user