显示调整
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; }
|
||||
|
||||
Reference in new issue
Block a user