创建碰撞与路径
This commit is contained in:
1 parent
f2fe653c04
commit
f27167621c
4 files changed
+783
-56
No files matched your search
+218
-33
@@ -2,36 +2,60 @@
|
||||
<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>
|
||||
<!-- 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 placeholder-action" title="添加 Spine 骨架(待开发)" @click.stop="showScenePlaceholder('Spine 骨架')">+骨架</button>
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<span class="scene-object-kind">路径</span>
|
||||
<button class="scene-object-delete" title="删除路径" @click.stop="store.removePath(path.id)">✕</button>
|
||||
</div>
|
||||
<div v-if="!store.systems.length && !store.colliders.length && !store.paths.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" />
|
||||
@@ -46,8 +70,69 @@
|
||||
<button class="seed-random" title="生成新的全局随机种子" @click="randomizeSeed">⚄</button>
|
||||
</div>
|
||||
<div class="seed-hint">控制初始随机参数、资源选择和修改器随机采样;相同种子可复现相同效果</div>
|
||||
</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-block"><span>碰撞体标签</span>
|
||||
<div class="tag-editor">
|
||||
<button v-for="tagName in activeCollider.tags" :key="tagName" class="object-tag" :class="{ on: activeCollider.selectedTag === tagName }" @click="activeCollider.selectedTag = tagName">{{ tagName }}</button>
|
||||
<button class="tag-add" title="新增标签" @click="addColliderTag">+</button>
|
||||
</div>
|
||||
<small>点击标签为当前碰撞体选择标签;点击+新增标签。</small>
|
||||
</div>
|
||||
<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">
|
||||
<NumSlider label="X 位置" :min="-2000" :max="2000" :step="1" v-model="activeCollider.x" />
|
||||
<NumSlider label="Y 位置" :min="-2000" :max="2000" :step="1" v-model="activeCollider.y" />
|
||||
</div>
|
||||
<NumSlider label="旋转" :min="-360" :max="360" :step="1" v-model="activeCollider.rotation" />
|
||||
<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">
|
||||
<NumSlider label="X 半径" :min="1" :max="1000" :step="1" v-model="activeCollider.radiusX" />
|
||||
<NumSlider label="Y 半径" :min="1" :max="1000" :step="1" v-model="activeCollider.radiusY" />
|
||||
</div>
|
||||
<div v-else class="field-grid">
|
||||
<NumSlider label="宽度" :min="1" :max="2000" :step="1" v-model="activeCollider.width" />
|
||||
<NumSlider label="高度" :min="1" :max="2000" :step="1" v-model="activeCollider.height" />
|
||||
</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">
|
||||
<NumSlider label="弹性 (0-1)" :min="0" :max="1" :step="0.01" v-model="activeCollider.elasticity" />
|
||||
<NumSlider label="摩擦 (0-1)" :min="0" :max="1" :step="0.01" v-model="activeCollider.friction" />
|
||||
</div>
|
||||
<label class="follow-path"><input v-model="activeCollider.followPath" type="checkbox" />跟随轨道移动</label>
|
||||
</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>
|
||||
<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>
|
||||
<div v-else class="panel-hint">使用右上角按钮创建场景对象</div>
|
||||
<div v-if="sceneActionHint" class="scene-action-hint">{{ sceneActionHint }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="activeObjectType === 'particle' && sys">
|
||||
|
||||
<!-- 2. 发射模式 -->
|
||||
<div class="group">
|
||||
@@ -550,12 +635,11 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else-if="!store.systems.length" class="panel-hint">点「+ 系统」创建粒子系统</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, markRaw, reactive, ref, watchEffect } from 'vue'
|
||||
import { computed, markRaw, onUnmounted, reactive, ref, watchEffect } from 'vue'
|
||||
import { Texture } from 'pixi.js'
|
||||
import { useParticleStore } from '../store/particleStore'
|
||||
import { ensureEmitterConfig } from '../core/particleEmitter'
|
||||
@@ -574,6 +658,13 @@ const activeIdx = computed(() => {
|
||||
return i >= 0 ? i : 0
|
||||
})
|
||||
const sys = computed(() => store.systems[activeIdx.value] || null)
|
||||
const activeObjectType = computed(() => store.activeObjectType)
|
||||
const activeCollider = computed(() => store.activeObjectType === 'collision'
|
||||
? store.colliders.find((item) => item.id === store.activeObjectId) || null
|
||||
: null)
|
||||
const activePath = computed(() => store.activeObjectType === 'path'
|
||||
? store.paths.find((item) => item.id === store.activeObjectId) || null
|
||||
: null)
|
||||
|
||||
// 开发热更新会保留旧的 Pinia 对象;渲染属性控件前先补齐新增字段。
|
||||
watchEffect(() => {
|
||||
@@ -606,6 +697,58 @@ function toggle(key: string) { collapsed[key] = !collapsed[key] }
|
||||
|
||||
function select(i: number) { store.selectSystem(store.systems[i]?.id ?? 0) }
|
||||
function addSys() { store.addSystem() }
|
||||
function addCollider() { store.addCollider() }
|
||||
function addPath() { store.addPath() }
|
||||
function addColliderTag() {
|
||||
const collider = activeCollider.value
|
||||
if (!collider) return
|
||||
let index = collider.tags.length + 1
|
||||
let name = `Tag ${index}`
|
||||
while (collider.tags.includes(name)) name = `Tag ${++index}`
|
||||
collider.tags.push(name)
|
||||
collider.selectedTag = name
|
||||
}
|
||||
function pathAnchor(x: number, y: number, inX = 0, inY = 0, outX = 0, outY = 0) {
|
||||
return { x, y, inX, inY, outX, outY }
|
||||
}
|
||||
function applyPathPreset() {
|
||||
const path = activePath.value
|
||||
if (!path || !path.preset) return
|
||||
if (path.preset === 'circle') {
|
||||
const radius = 110, handle = radius * 0.5522848
|
||||
path.points = [
|
||||
pathAnchor(radius, 0, 0, -handle, 0, handle),
|
||||
pathAnchor(0, radius, handle, 0, -handle, 0),
|
||||
pathAnchor(-radius, 0, 0, handle, 0, -handle),
|
||||
pathAnchor(0, -radius, -handle, 0, handle, 0),
|
||||
]
|
||||
path.closed = true
|
||||
} else if (path.preset === 'square') {
|
||||
path.points = [pathAnchor(-100, -100), pathAnchor(100, -100), pathAnchor(100, 100), pathAnchor(-100, 100)]
|
||||
path.closed = true
|
||||
} else {
|
||||
const sides = path.preset === 'star' ? 10 : 6
|
||||
path.points = Array.from({ length: sides }, (_, index) => {
|
||||
const radius = path.preset === 'star' && index % 2 ? 50 : 110
|
||||
const angle = -Math.PI * 0.5 + index / sides * Math.PI * 2
|
||||
return pathAnchor(Math.cos(angle) * radius, Math.sin(angle) * radius)
|
||||
})
|
||||
path.closed = true
|
||||
}
|
||||
}
|
||||
function clearPathAnchors() {
|
||||
if (activePath.value) activePath.value.points = []
|
||||
}
|
||||
const sceneActionHint = ref('')
|
||||
let sceneActionHintTimer: ReturnType<typeof setTimeout> | null = null
|
||||
function showScenePlaceholder(type: string) {
|
||||
sceneActionHint.value = `${type}对象将在后续阶段开发`
|
||||
if (sceneActionHintTimer) clearTimeout(sceneActionHintTimer)
|
||||
sceneActionHintTimer = setTimeout(() => { sceneActionHint.value = '' }, 2400)
|
||||
}
|
||||
onUnmounted(() => {
|
||||
if (sceneActionHintTimer) clearTimeout(sceneActionHintTimer)
|
||||
})
|
||||
function randomizeSeed() {
|
||||
if (sys.value) sys.value.config.seed = Math.floor(Math.random() * 999999) + 1
|
||||
}
|
||||
@@ -1020,10 +1163,6 @@ function toggleResourceLock(resourceId: number) {
|
||||
.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 {
|
||||
@@ -1031,6 +1170,18 @@ function toggleResourceLock(resourceId: number) {
|
||||
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.placeholder-action { background: #232938; color: #9da7be; }
|
||||
.scene-action-hint {
|
||||
margin-top: 8px; padding: 6px 8px; border: 1px dashed #3a4963; border-radius: 5px;
|
||||
background: #181e2b; color: #8f9ab3; font-size: 11px;
|
||||
}
|
||||
.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; }
|
||||
@@ -1039,7 +1190,41 @@ function toggleResourceLock(resourceId: number) {
|
||||
.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 .tlabel { flex: 1; }
|
||||
.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-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; }
|
||||
.field-grid :deep(.num-slider) { min-width: 0; }
|
||||
.check-field { align-items: flex-start; }
|
||||
.check-field input { width: 18px; height: 18px; margin: 4px 0; accent-color: #4f7ee8; }
|
||||
.tag-editor { display: flex; flex-wrap: wrap; gap: 5px; }
|
||||
.object-tag, .tag-add { height: 26px; padding: 0 8px; border: 1px solid #3b4861; border-radius: 5px; background: #242d40; color: #aeb8cf; cursor: pointer; }
|
||||
.object-tag.on { border-color: #7774ff; background: #514fd0; color: #fff; }
|
||||
.tag-add { width: 28px; padding: 0; border-style: dashed; font-size: 17px; }
|
||||
.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; }
|
||||
.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; }
|
||||
.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; }
|
||||
|
||||
Reference in new issue
Block a user