资源拆分

This commit is contained in:
tianmo committed 2026-09-11 10:00:38 +08:00
1 parent f810e712ec
commit 83890faaea
38 files changed
+4586 -4144

No files matched your search

@@ -0,0 +1,275 @@
<template>
<!-- 1. 场景对象:创建入口始终可用,即使当前没有粒子系统。 -->
<div class="group">
<div class="group-head scene-head" @click="toggle('scene')">
<span class="caret">{{ collapsed.scene ? '▸' : '▾' }}</span><span>场景对象</span>
<div class="scene-actions">
<button class="scene-add" title="添加粒子系统" @click.stop="addSys">+粒子</button>
<button class="scene-add" title="添加碰撞体" @click.stop="addCollider">+碰撞</button>
<button class="scene-add" title="添加路径" @click.stop="addPath">+路径</button>
<button class="scene-add" title="添加 Spine" @click.stop="addSpine">+Spine</button>
<button class="scene-add" :disabled="!canCopyObject" title="复制当前选中的场景对象" @click.stop="copySelectedObject">+Copy</button>
</div>
</div>
<div class="group-body" v-show="!collapsed.scene">
<div class="tree scene-tree">
<div
v-for="(system, i) in store.systems"
:key="`particle-${system.id}`"
class="tree-item scene-object-item"
:class="{ on: activeObjectType === 'particle' && system.id === store.activeObjectId }"
@click="select(i)"
>
<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>
<div
v-for="collider in store.colliders"
:key="`collision-${collider.id}`"
class="tree-item scene-object-item"
:class="{ on: activeObjectType === 'collision' && collider.id === store.activeObjectId }"
@click="store.selectSceneObject('collision', collider.id)"
>
<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>
<div
v-for="path in store.paths"
:key="`path-${path.id}`"
class="tree-item scene-object-item"
:class="{ on: activeObjectType === 'path' && path.id === store.activeObjectId }"
@click="store.selectSceneObject('path', path.id)"
>
<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>
<div
v-for="spine in store.spines"
:key="`spine-${spine.id}`"
class="tree-item scene-object-item"
:class="{ on: activeObjectType === 'spine' && spine.id === store.activeObjectId }"
@click="store.selectSceneObject('spine', spine.id)"
>
<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>
<div v-if="!store.systems.length && !store.colliders.length && !store.paths.length && !store.spines.length" class="empty">场景中暂无对象</div>
</div>
<template v-if="activeObjectType === 'particle' && sys">
<label class="row"><span>名称</span><input v-model="sys.config.name" class="inp" /></label>
<NumSlider label="位置X" :min="-2000" :max="2000" :step="1" v-model="sys.config.centerX" />
<NumSlider label="位置Y" :min="-2000" :max="2000" :step="1" v-model="sys.config.centerY" />
<NumSlider label="偏移X" :min="-2000" :max="2000" :step="1" v-model="sys.config.offsetX" />
<NumSlider label="偏移Y" :min="-2000" :max="2000" :step="1" v-model="sys.config.offsetY" />
<NumSlider label="旋转" :min="-360" :max="360" :step="1" v-model="sys.config.rootRotation" />
<NumSlider label="缩放X" :min="0.05" :max="10" :step="0.05" v-model="sys.config.rootScaleX" />
<NumSlider label="缩放Y" :min="0.05" :max="10" :step="0.05" v-model="sys.config.rootScaleY" />
<div class="seed-row scene-seed">
<label for="system-seed">随机种子</label>
<input id="system-seed" class="seed-input" type="number" min="0" step="1" v-model.number="sys.config.seed" title="控制粒子系统内全部随机属性、资源选择与修改器随机采样" />
<button class="seed-random" title="生成新的全局随机种子" @click="randomizeSeed">⚄</button>
</div>
<div class="seed-hint">控制初始随机参数、资源选择和修改器随机采样;相同种子可复现相同效果</div>
</template>
<template v-else-if="activeCollider">
<div class="object-editor-title">碰撞体属性</div>
<label class="field-block"><span>碰撞体名称</span><input v-model="activeCollider.name" class="inp" /></label>
<div class="field-grid">
<label class="field-block"><span>类型</span><select v-model="activeCollider.shape" class="inp">
<option value="circle">圆形</option><option value="ellipse">椭圆</option><option value="rect">矩形</option><option value="polygon">多边形</option><option value="custom">自定义</option>
</select></label>
<label class="field-block check-field"><span>启用</span><input v-model="activeCollider.enabled" type="checkbox" /></label>
</div>
<div class="field-grid">
<label class="compact-field"><span>X 位置</span><input v-model.number="activeCollider.x" type="number" class="inp" /></label>
<label class="compact-field"><span>Y 位置</span><input v-model.number="activeCollider.y" type="number" class="inp" /></label>
</div>
<NumSlider label="旋转" :min="-360" :max="360" :step="1" v-model="activeCollider.rotation" />
<div class="field-grid">
<label class="compact-field"><span>缩放 X</span><input v-model.number="activeCollider.scaleX" type="number" min="0.05" step="0.05" class="inp" /></label>
<label class="compact-field"><span>缩放 Y</span><input v-model.number="activeCollider.scaleY" type="number" min="0.05" step="0.05" class="inp" /></label>
</div>
<NumSlider v-if="activeCollider.shape === 'circle'" label="半径" :min="1" :max="1000" :step="1" v-model="activeCollider.radius" />
<div v-else-if="activeCollider.shape === 'ellipse'" class="field-grid">
<label class="compact-field"><span>X 半径</span><input v-model.number="activeCollider.radiusX" type="number" min="1" class="inp" /></label>
<label class="compact-field"><span>Y 半径</span><input v-model.number="activeCollider.radiusY" type="number" min="1" class="inp" /></label>
</div>
<div v-else class="field-grid">
<label class="compact-field"><span>宽度</span><input v-model.number="activeCollider.width" type="number" min="1" class="inp" /></label>
<label class="compact-field"><span>高度</span><input v-model.number="activeCollider.height" type="number" min="1" class="inp" /></label>
</div>
<NumSlider v-if="activeCollider.shape === 'polygon' || activeCollider.shape === 'custom'" label="边数" :min="3" :max="16" :step="1" v-model="activeCollider.sides" />
<label class="field-block"><span>碰撞响应</span><select v-model="activeCollider.response" class="inp">
<option value="physics">物理(弹跳/摩擦)</option><option value="pause">暂停(停止不动)</option><option value="destroy">消失(立即销毁)</option>
</select></label>
<div v-if="activeCollider.response === 'physics'" class="field-grid">
<label class="compact-field"><span>弹性 (0-1)</span><input v-model.number="activeCollider.elasticity" type="number" min="0" max="1" step="0.01" class="inp" /></label>
<label class="compact-field"><span>摩擦 (0-1)</span><input v-model.number="activeCollider.friction" type="number" min="0" max="1" step="0.01" class="inp" /></label>
</div>
<div class="collider-follow-section">
<label class="follow-path"><input v-model="activeCollider.followPath" type="checkbox" />跟随路径移动</label>
<template v-if="activeCollider.followPath">
<label class="field-block"><span>目标路径</span><select v-model.number="activeCollider.followPathId" 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 class="field-grid">
<label class="compact-field"><span>移动时长(秒)</span><input v-model.number="activeCollider.followDuration" type="number" min="0.05" step="0.05" class="inp" /></label>
<label class="compact-field"><span>曲线模式</span><select v-model="activeCollider.followCurveMode" class="inp">
<option>跟随导出</option><option>线性</option><option>贝塞尔</option>
</select></label>
</div>
<label class="follow-path"><input v-model="activeCollider.followPathRotation" type="checkbox" />方向沿路径</label>
<div class="collider-curve-title">移动进度曲线 <small>横轴:实时/移动时长 纵轴:路径进度(0=起点 → 1=终点)</small></div>
<CurveEditor
:model-value="activeCollider.followCurve || LINEAR_CURVE"
:default-value="LINEAR_CURVE"
:mode="resolvedCurveEditorMode(activeCollider.followCurveMode)"
@update:model-value="activeCollider.followCurve = $event"
/>
</template>
</div>
</template>
<template v-else-if="activePath">
<div class="object-editor-title">路径属性</div>
<label class="field-block"><span>路径名称</span><input v-model="activePath.name" class="inp" /></label>
<div class="field-grid">
<label class="compact-field"><span>X 位置</span><input v-model.number="activePath.x" type="number" class="inp" /></label>
<label class="compact-field"><span>Y 位置</span><input v-model.number="activePath.y" type="number" class="inp" /></label>
</div>
<NumSlider label="旋转" :min="-360" :max="360" :step="1" v-model="activePath.rotation" />
<div class="field-grid">
<label class="compact-field"><span>缩放 X</span><input v-model.number="activePath.scaleX" type="number" min="0.05" step="0.05" class="inp" /></label>
<label class="compact-field"><span>缩放 Y</span><input v-model.number="activePath.scaleY" type="number" min="0.05" step="0.05" class="inp" /></label>
</div>
<label class="setting-line"><span>闭合路径</span><input v-model="activePath.closed" type="checkbox" /></label>
<label class="setting-line"><span>路径颜色</span><input v-model="activePath.color" type="color" class="path-color" /></label>
<div class="path-help"><span>操作说明:</span><ul>
<li>双击空白处/曲线:新建锚点</li><li>双击锚点:删除节点</li><li>双击第二下按住拖动:新建并拉出手柄</li>
<li>拖动锚点移动位置,拖动手柄调整曲线</li><li>Shift+拖动手柄:两侧完全对称</li><li>Alt+拖动手柄:独立控制单侧</li>
<li>Ctrl+点击/框选:加选节点</li><li>Alt+点击/框选:减选节点</li><li>Alt+单击空白处:清除全部选中</li><li>选中多个节点后可整体拖动</li>
</ul></div>
<label class="field-block"><span>应用预设形状</span><select v-model="activePath.preset" class="inp">
<option value="">选择预设...</option><option value="circle">圆形</option><option value="square">方形</option><option value="polygon">多边形</option><option value="star">星形</option>
</select></label>
<button class="path-action primary" @click="applyPathPreset">应用预设</button>
<button class="path-action" @click="clearPathAnchors">清空锚点</button>
</template>
<SpineObjectPanel v-else-if="activeSpine" :spine-object="activeSpine" />
<div v-else class="panel-hint">使用右上角按钮创建场景对象</div>
</div>
</div>
</template>
<script setup lang="ts">
import NumSlider from '../NumSlider.vue'
import CurveEditor from '../CurveEditor.vue'
import ParticleAttributeControl from '../ParticleAttributeControl.vue'
import ColorGradientEditor from '../ColorGradientEditor.vue'
import ImageResourceCard from '../ImageResourceCard.vue'
import SpineObjectPanel from '../../spine/SpineObjectPanel.vue'
import { useParticlePanelContext } from './particlePanelContext'
const { store, resolvedCurveEditorMode, sys, activeObjectType, activeCollider, activePath, activeSpine, canCopyObject, collapsed, toggle, select, addSys, addCollider, addPath, addSpine, copySelectedObject, applyPathPreset, clearPathAnchors, randomizeSeed, LINEAR_CURVE } = useParticlePanelContext()
</script>
<style scoped>
.panel { display: flex; width: 100%; height: 100%; flex-direction: column; overflow: hidden; background: #14141f; border-left: 1px solid #2a2a3c; padding: 12px; box-sizing: border-box; }
.empty, .panel-hint { color: #667; font-size: 12px; padding: 8px 0; }
.group { border: 1px solid #2a2a3c; border-radius: 8px; margin-bottom: 10px; overflow: hidden; }
.group-head {
display: flex; align-items: center; gap: 6px; padding: 7px 10px; cursor: pointer;
font-size: 12px; font-weight: 600; color: #ccd; background: #191924; user-select: none;
}
.group-head:hover { background: #20202e; }
.scene-head { padding-right: 6px; }
.scene-actions { margin-left: auto; display: flex; align-items: center; gap: 4px; }
.scene-add {
height: 24px; padding: 0 6px; border: 1px solid #3a4a6a; border-radius: 5px;
background: #2e3a55; color: #cdf; font-size: 10px; white-space: nowrap; cursor: pointer;
}
.scene-add:hover { border-color: #7774ff; background: #45426f; color: #fff; }
.scene-add:disabled { opacity: 0.42; cursor: not-allowed; border-color: #343a4b; background: #252a38; color: #778095; }
.group-body { padding: 8px 10px 12px; }
.caret { display: inline-block; width: 12px; color: #667; font-size: 11px; }
.tree { margin-bottom: 8px; }
.tree-item { display: flex; align-items: center; gap: 6px; padding: 5px 6px; font-size: 12px; color: #bcd; border-radius: 6px; margin-bottom: 2px; }
.tree-item.on { background: #26324a; color: #ffe; }
.tree-item .tdot { color: #ffcc33; font-size: 10px; }
.tree-item .tdot.dim { color: #556; }
.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; 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;
}
.scene-object-delete:hover { border-color: #a45c69; background: #57303a; color: #fff; }
.object-editor-title { margin: 10px 0 8px; padding-top: 8px; border-top: 1px solid #2a2a3c; color: #cbd4e8; font-weight: 700; }
.field-block { display: flex; flex-direction: column; gap: 5px; margin: 10px 0; color: #8f9ab3; font-size: 11px; }
.field-block > .inp { width: 100%; box-sizing: border-box; min-height: 30px; }
.field-block small { color: #5f6d86; line-height: 1.5; }
.field-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px; align-items: end; }
.compact-field { display: flex; min-width: 0; flex-direction: column; gap: 5px; margin: 5px 0; color: #8f9ab3; font-size: 11px; }
.compact-field .inp { width: 100%; min-height: 30px; box-sizing: border-box; }
.check-field { align-items: flex-start; }
.check-field input { width: 18px; height: 18px; margin: 4px 0; accent-color: #4f7ee8; }
.follow-path, .setting-line { display: flex; align-items: center; gap: 8px; margin: 12px 0 4px; color: #aeb8cf; font-size: 12px; }
.setting-line { justify-content: space-between; }
.follow-path input, .setting-line input[type='checkbox'] { width: 17px; height: 17px; accent-color: #4f7ee8; }
.collider-follow-section { margin-top: 12px; padding-top: 4px; border-top: 1px solid #30394d; }
.collider-curve-title { display: flex; flex-direction: column; gap: 3px; margin: 12px 0 6px; color: #aeb8cf; font-size: 12px; }
.collider-curve-title small { color: #67738c; font-size: 10px; line-height: 1.45; }
.path-color { width: 48px; height: 28px; padding: 0; border: 1px solid #3a4963; background: transparent; cursor: pointer; }
.path-help { margin: 12px 0; color: #7d89a2; font-size: 11px; line-height: 1.55; }
.path-help > span { color: #aeb8cf; }
.path-help ul { margin: 6px 0 0; padding-left: 20px; }
.path-help li { margin: 3px 0; }
.path-action { width: 100%; height: 31px; margin-top: 7px; border: 0; border-radius: 5px; background: #33425b; color: #c9d1e1; cursor: pointer; }
.path-action:hover { filter: brightness(1.12); }
.path-action.primary { background: #f05408; color: #fff; }
.row { display: flex; align-items: center; gap: 8px; margin: 6px 0; font-size: 12px; color: #aab; }
.row > span:first-child { width: 60px; flex-shrink: 0; color: #8a93bb; }
.inp { flex: 1; min-width: 0; padding: 3px 5px; background: #1a1a28; border: 1px solid #2e2e44; border-radius: 5px; color: #dde; font-size: 12px; }
.inp.tiny { width: 46px; flex: 0 0 46px; text-align: center; }
.seed-row { display: flex; align-items: center; gap: 8px; margin-top: 12px; padding-top: 12px; border-top: 1px solid #2d3950; }
.seed-row label { width: 64px; color: #8f9bb5; font-size: 12px; }
.seed-input { min-width: 0; flex: 1; padding: 6px 8px; border: 1px solid #344158; border-radius: 6px; outline: none; background: #0f1625; color: #e2e7f2; font-size: 12px; }
.seed-input:focus { border-color: #625ff1; }
.seed-random { width: 34px; height: 30px; border: 1px solid #3a4860; border-radius: 6px; background: #2c3950; color: #d7def0; cursor: pointer; }
.seed-random:hover { background: #394964; }
.scene-seed { margin-top: 10px; }
.seed-hint { margin: 5px 0 1px 72px; color: #657188; font-size: 9px; line-height: 1.45; }
</style>