首次上传

This commit is contained in:
tianmo committed 2026-08-27 17:23:39 +08:00
1 parent af9bda066c
commit 9b156a32c9
29 files changed
+14567

No files matched your search

+331
View File
@@ -0,0 +1,331 @@
<template>
<div class="panel">
<div class="panel-header">
<span>粒子系统 · 编辑器</span>
<button class="mini-btn" @click="addSys">+ 系统</button>
</div>
<!-- 系统列表 = 场景对象层级 -->
<div class="sys-list">
<div
v-for="(sys, i) in store.systems"
:key="sys.id"
class="sys-item"
:class="{ active: i === activeIdx }"
@click="select(i)"
>
<span class="sys-name">{{ sys.config.name || '系统 ' + (i + 1) }}</span>
<button class="mini-btn danger" @click.stop="store.removeSystem(sys.id)">✕</button>
</div>
<div v-if="!store.systems.length" class="empty">尚无粒子系统</div>
</div>
<template v-if="sys">
<!-- 1. 场景对象 -->
<div class="group">
<div class="group-head" @click="toggle('scene')">
<span class="caret">{{ collapsed.scene ? '▸' : '▾' }}</span><span>场景对象</span>
</div>
<div class="group-body" v-show="!collapsed.scene">
<div class="tree">
<div class="tree-item on"><span class="tdot">●</span><span class="tlabel">粒子系统</span></div>
<div class="tree-item"><span class="tdot dim">◇</span><span class="tlabel">轨道路径</span><span class="tag">预留</span></div>
<div class="tree-item"><span class="tdot dim">◇</span><span class="tlabel">碰撞体</span><span class="tag">预留</span></div>
</div>
<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>
</div>
<!-- 2. 发射模式 -->
<div class="group">
<div class="group-head" @click="toggle('emitMode')">
<span class="caret">{{ collapsed.emitMode ? '▸' : '▾' }}</span><span>发射模式</span>
</div>
<div class="group-body" v-show="!collapsed.emitMode">
<div class="segs">
<button class="seg" :class="{ on: sys.config.mode === 'stream' }" @click="sys.config.mode = 'stream'">持续</button>
<button class="seg" :class="{ on: sys.config.mode === 'burst' }" @click="sys.config.mode = 'burst'">爆发</button>
</div>
<NumSlider v-if="sys.config.mode === 'stream'" label="发射速率" :min="1" :max="300" :step="1" v-model="sys.config.rate" />
<template v-else>
<NumSlider label="单次数量" :min="1" :max="1000" :step="1" v-model="sys.config.burstCount" />
</template>
<NumSlider label="延迟时间" :min="0" :max="5" :step="0.1" v-model="sys.config.delay" />
</div>
</div>
<!-- 3. 发射器形状 -->
<div class="group">
<div class="group-head" @click="toggle('shape')">
<span class="caret">{{ collapsed.shape ? '▸' : '▾' }}</span><span>发射器形状</span>
</div>
<div class="group-body" v-show="!collapsed.shape">
<label class="row"><span>形状</span>
<select v-model="sys.config.shape" class="inp" @change="onShapeChange">
<option value="point">点</option>
<option value="circle">圆形</option>
<option value="rect">矩形</option>
<option value="cone">锥体</option>
<option value="orbit">轨道</option>
</select>
</label>
<template v-if="sys.config.shape === 'circle' || sys.config.shape === 'cone'">
<NumSlider label="半径" :min="0" :max="300" :step="1" v-model="sys.config.radius" />
</template>
<template v-if="sys.config.shape === 'cone'">
<NumSlider label="锥形开口角度" :min="1" :max="360" :step="1" v-model="sys.config.coneAngle" />
</template>
<template v-else-if="sys.config.shape === 'rect'">
<NumSlider label="宽" :min="1" :max="400" :step="1" v-model="sys.config.rectW" />
<NumSlider label="高" :min="1" :max="400" :step="1" v-model="sys.config.rectH" />
</template>
<div class="divider"></div>
<NumSlider :label="sys.config.shape === 'cone' ? '锥形朝向' : '发射角度'" :min="-360" :max="360" :step="1" v-model="sys.config.direction" />
<NumSlider v-if="sys.config.shape !== 'cone'" label="扩散角度" :min="0" :max="360" :step="1" v-model="sys.config.spread" />
</div>
</div>
<!-- 4. 粒子属性 -->
<div class="group">
<div class="group-head" @click="toggle('attr')">
<span class="caret">{{ collapsed.attr ? '▸' : '▾' }}</span><span>粒子属性</span>
</div>
<div class="group-body" v-show="!collapsed.attr">
<NumSlider label="速度下限" :min="0" :max="400" :step="1" v-model="sys.config.speedMin" />
<NumSlider label="速度上限" :min="0" :max="600" :step="1" v-model="sys.config.speedMax" />
<NumSlider label="生命下限" :min="0.1" :max="5" :step="0.1" v-model="sys.config.lifeMin" />
<NumSlider label="生命上限" :min="0.1" :max="6" :step="0.1" v-model="sys.config.lifeMax" />
<NumSlider label="缩放下限" :min="0.05" :max="3" :step="0.05" v-model="sys.config.scaleMin" />
<NumSlider label="缩放上限" :min="0.05" :max="5" :step="0.05" v-model="sys.config.scaleMax" />
<NumSlider label="旋转速度" :min="-360" :max="360" :step="10" v-model="sys.config.rotationSpeedMin" />
<NumSlider label="随机种子" :min="0" :max="9999" :step="1" v-model="sys.config.seed" />
</div>
</div>
<!-- 5. 外观与资源 -->
<div class="group">
<div class="group-head" @click="toggle('look')">
<span class="caret">{{ collapsed.look ? '▸' : '▾' }}</span><span>外观与资源</span>
</div>
<div class="group-body" v-show="!collapsed.look">
<!-- 透明度 -->
<div class="look-title">透明度</div>
<div class="seg">
<button class="seg-btn" :class="{ active: sys.config.alphaMode === 'curve' }" @click="sys.config.alphaMode = 'curve'">曲线</button>
<button class="seg-btn" :class="{ active: sys.config.alphaMode === 'fixed' }" @click="sys.config.alphaMode = 'fixed'">固定</button>
</div>
<template v-if="sys.config.alphaMode === 'fixed'">
<NumSlider label="透明度" :min="0" :max="1" :step="0.01" v-model="sys.config.alpha" />
</template>
<template v-else>
<label class="row"><span>曲线模式</span>
<select v-model="sys.config.curveMode" class="inp">
<option>跟随导出</option><option>线性</option><option>贝塞尔</option>
</select>
</label>
<div class="curve-wrap">
<CurveEditor v-model="sys.config.alphaCurve" />
<div class="curve-labels"><span>0(透明)</span><span>生命周期 →</span><span>1(不透明)</span></div>
</div>
</template>
<!-- 图片资源 -->
<div class="look-title">图片资源</div>
<div class="imgrow">
<div class="img-thumb"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="#8a93bb" stroke-width="1.6"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.6"/><path d="M21 15l-5-5-9 9"/></svg></div>
<div class="img-fields">
<label class="row"><span>子文件夹</span><input type="text" v-model="sys.config.textureFolder" class="inp" placeholder="subfolder/" /></label>
<label class="row"><span>名称</span><input type="text" v-model="sys.config.textureName" class="inp" placeholder="particle" /></label>
</div>
<div class="img-actions">
<button class="mini-btn2" title="重置" @click="resetTexture()">⟳</button>
<button class="mini-btn2" title="删除" @click="clearTexture()">🗑</button>
</div>
</div>
<div class="thumb-note">{{ sys.config.textureName || 'star' }}.png(默认 star.png)</div>
<!-- 颜色 -->
<div class="look-title">颜色</div>
<label class="row"><span>颜色类型</span>
<select :value="sys.config.colorOverLife ? '渐变' : '固定色'" class="inp" @change="sys.config.colorOverLife = ($event.target as any).value === '渐变'">
<option>固定色</option><option>渐变</option>
</select>
<select v-model="sys.config.blend" class="inp">
<option value="normal">正常</option><option value="add">相加</option>
<option value="multiply">相乘</option><option value="screen">滤色</option>
</select>
</label>
<template v-if="sys.config.colorOverLife">
<label class="row"><span>起始色</span><input type="color" v-model="sys.config.colorStart" class="colr" /></label>
<label class="row"><span>结束色</span><input type="color" v-model="sys.config.colorEnd" class="colr" /></label>
</template>
<template v-else>
<label class="row"><span>颜色</span><input type="color" v-model="sys.config.colorStart" class="colr" />
<span class="hex">{{ sys.config.colorStart }}</span></label>
</template>
<!-- 占比 / 缩放 / 轴心 / 独立透明度 -->
<div class="look-title">占比与缩放</div>
<NumSlider label="占比" :min="0" :max="1" :step="0.01" v-model="sys.config.alphaScale" />
<NumSlider label="缩放" :min="0.1" :max="3" :step="0.1" v-model="sys.config.scaleMax" />
<label class="row"><span>轴心 x,y</span><input type="text" class="inp tiny" v-model.number="sys.config.anchorX" /><input type="text" class="inp tiny" v-model.number="sys.config.anchorY" /></label>
<label class="row"><span>独立透明度</span>
<select v-model="sys.config.independentAlpha" class="inp">
<option value="off">关闭</option><option value="fixed">固定</option><option value="curve">曲线</option>
</select>
</label>
</div>
</div>
<!-- 6. 修改器 (Modifiers) -->
<div class="group">
<div class="group-head" @click="toggle('mods')">
<span class="caret">{{ collapsed.mods ? '▸' : '▾' }}</span><span>修改器 (Modifiers)</span>
</div>
<div class="group-body" v-show="!collapsed.mods">
<label class="row"><span>移动噪声</span><input type="checkbox" v-model="sys.config.noise" /></label>
<NumSlider v-if="sys.config.noise" label="噪声强度" :min="0" :max="200" :step="5" v-model="sys.config.noiseAmt" />
<label class="row"><span>重力</span><input type="checkbox" v-model="sys.config.useGravity" /></label>
<NumSlider v-if="sys.config.useGravity" label="重力大小" :min="-500" :max="500" :step="10" v-model="sys.config.gravity" />
<label class="row"><span>风力</span><input type="checkbox" v-model="sys.config.wind" /></label>
<template v-if="sys.config.wind">
<NumSlider label="风力X" :min="-300" :max="300" :step="1" v-model="sys.config.windX" />
<NumSlider label="风力Y" :min="-300" :max="300" :step="1" v-model="sys.config.windY" />
</template>
<label class="row"><span>力场</span><input type="checkbox" v-model="sys.config.forceField" /></label>
<template v-if="sys.config.forceField">
<NumSlider label="力场强度" :min="-300" :max="300" :step="5" v-model="sys.config.forceStrength" />
<NumSlider label="力场半径" :min="1" :max="500" :step="1" v-model="sys.config.forceRadius" />
</template>
<label class="row"><span>拖尾</span><input type="checkbox" v-model="sys.config.trail" /></label>
<label class="row"><span>碰撞</span><input type="checkbox" v-model="sys.config.collision" /><span class="tag">预留</span></label>
<label class="row"><span>路径跟随</span><input type="checkbox" v-model="sys.config.pathFollow" /><span class="tag">预留</span></label>
<NumSlider label="阻尼" :min="0" :max="2" :step="0.05" v-model="sys.config.damping" />
</div>
</div>
<!-- 7. 导出设置(预留) -->
<div class="group">
<div class="group-head" @click="toggle('export')">
<span class="caret">{{ collapsed.export ? '▸' : '▾' }}</span><span>导出设置</span><span class="tag">预留</span>
</div>
<div class="group-body" v-show="!collapsed.export">
<div class="placeholder">导出功能后续实现,已预留位置</div>
</div>
</div>
</template>
<div v-else-if="!store.systems.length" class="panel-hint">点「+ 系统」创建粒子系统</div>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { useParticleStore } from '../store/particleStore'
import NumSlider from './NumSlider.vue'
import CurveEditor from './CurveEditor.vue'
const store = useParticleStore()
const activeIdx = computed(() => {
const i = store.systems.findIndex((s) => s.id === store.activeId)
return i >= 0 ? i : 0
})
const sys = computed(() => store.systems[activeIdx.value] || null)
const collapsed = reactive<Record<string, boolean>>({
scene: false, emitMode: false, shape: false, attr: false, look: false, mods: false, export: false,
})
function toggle(key: string) { collapsed[key] = !collapsed[key] }
function select(i: number) { store.selectSystem(store.systems[i]?.id ?? 0) }
function addSys() { store.addSystem() }
// 各发射器形状的默认参数:切换形状时应用(只影响形状相关字段,不牵连其他)
const SHAPE_DEFAULTS: Record<string, Partial<any>> = {
point: { direction: 0, spread: 360 },
circle: { radius: 100, direction: -90, spread: 0 },
rect: { rectW: 200, rectH: 100, direction: -90, spread: 0 },
cone: { radius: 100, coneAngle: 90, direction: 90 },
orbit: {},
}
function onShapeChange() {
const cfg = sys.value?.config
if (!cfg) return
const d = SHAPE_DEFAULTS[cfg.shape]
if (!d) return
Object.assign(cfg, d)
}
// 重置/删除图片 → 回到默认 star.png
function resetTexture() {
const c = sys.value?.config
if (c) { c.textureName = 'star'; c.textureFolder = '' }
}
function clearTexture() { resetTexture() }
</script>
<style scoped>
.panel { width: 100%; height: 100%; overflow-y: auto; background: #14141f; border-left: 1px solid #2a2a3c; padding: 12px; box-sizing: border-box; }
/* 深色主题滚动条:细窄、圆角、暗色,避免默认白条突兀 */
.panel::-webkit-scrollbar { width: 6px; }
.panel::-webkit-scrollbar-track { background: transparent; }
.panel::-webkit-scrollbar-thumb { background: #2e2e44; border-radius: 3px; }
.panel::-webkit-scrollbar-thumb:hover { background: #3a3a55; }
.panel { scrollbar-width: thin; scrollbar-color: #2e2e44 transparent; }
.panel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; font-weight: 700; color: #ccd; }
.sys-list { margin-bottom: 10px; display: flex; flex-direction: column; gap: 4px; }
.sys-item { display: flex; justify-content: space-between; align-items: center; padding: 6px 8px; background: #1e1e2e; border-radius: 6px; cursor: pointer; }
.sys-item.active { background: #2b3a5c; }
.sys-name { font-size: 12px; }
.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; }
.group-body { padding: 8px 10px 12px; }
.caret { display: inline-block; width: 12px; color: #667; font-size: 11px; }
.divider { height: 1px; background: #2a2a3c; margin: 8px 0; }
.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 .tlabel { flex: 1; }
.tag { font-size: 10px; color: #667; background: #1c1c2a; border-radius: 4px; padding: 1px 5px; }
.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; }
.pair { display: flex; flex-direction: column; gap: 2px; flex: 1; }
.colr { width: 28px; height: 22px; border: none; background: none; cursor: pointer; }
.segs { display: flex; gap: 6px; margin: 6px 0; }
.seg { flex: 1; padding: 5px; background: #1e1e2e; border: 1px solid #2e2e44; border-radius: 6px; color: #aab; font-size: 12px; cursor: pointer; }
.seg.on { background: #3a5a8c; color: #fff; border-color: #5a7ab8; }
.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; }
/* 外观与资源 参考版式 */
.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; }
.seg { display: flex; gap: 6px; margin: 6px 0; }
.seg-btn { flex: 1; padding: 5px; background: #1e1e2e; border: 1px solid #2e2e44; border-radius: 6px; color: #aab; font-size: 12px; cursor: pointer; }
.seg-btn.active { background: #3a5a8c; color: #fff; border-color: #5a7ab8; }
.curve-hint { min-height: 54px; border: 1px dashed #2e2e44; border-radius: 6px; display: flex; align-items: center; justify-content: center; color: #556; font-size: 11px; margin: 6px 0; }
.curve-wrap { margin: 6px 0; }
.curve-labels { display: flex; justify-content: space-between; color: #566; font-size: 10px; margin-top: 2px; }
.imgrow { display: flex; align-items: flex-start; gap: 8px; }
.img-thumb { width: 48px; height: 48px; border: 1px solid #2e2e44; border-radius: 6px; background: #1a1a28; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.img-fields { flex: 1; min-width: 0; }
.img-actions { display: flex; flex-direction: column; gap: 4px; }
.mini-btn2 { width: 26px; height: 24px; background: #1e1e2e; border: 1px solid #2e2e44; border-radius: 5px; color: #aab; font-size: 12px; cursor: pointer; display: flex; align-items: center; justify-content: center; }
.mini-btn2:hover { background: #2b3a5c; }
.thumb-note { color: #667; font-size: 11px; margin: 4px 0 2px; }
.hex { font-size: 11px; color: #889; margin-left: auto; font-variant-numeric: tabular-nums; }
.inp.tiny { width: 46px; flex: 0 0 46px; text-align: center; }
</style>