完成阶段3-4
This commit is contained in:
1 parent
f0f4104b0c
commit
4e2e123269
5 files changed
+1158
-50
No files matched your search
+131
-21
@@ -40,6 +40,12 @@
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -238,12 +244,6 @@
|
||||
curve-label="生命周期 →"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="seed-row">
|
||||
<label for="particle-seed">随机种子</label>
|
||||
<input id="particle-seed" class="seed-input" type="number" min="0" step="1" v-model.number="sys.config.seed" />
|
||||
<button class="seed-random" title="生成随机种子" @click="randomizeSeed">⚄</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -370,20 +370,108 @@
|
||||
<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>
|
||||
<div class="modifier-item">
|
||||
<label class="switch-row modifier-switch">
|
||||
<input type="checkbox" v-model="sys.config.noise" />
|
||||
<span class="switch-ui"></span><span>开启移动噪声 (Movement Noise)</span>
|
||||
</label>
|
||||
<div v-if="sys.config.noise" class="modifier-params">
|
||||
<NumSlider label="强度" :min="0" :max="100" :step="1" v-model="sys.config.noiseAmt" />
|
||||
<NumSlider label="波动范围" :min="0" :max="500" :step="1" v-model="sys.config.noiseRange" />
|
||||
<NumSlider label="时间流速" :min="0" :max="10" :step="0.1" v-model="sys.config.noiseSpeed" />
|
||||
<div class="modifier-hint">强度控制影响比例 · 波动范围控制最大偏移 · 时间流速控制变化速度</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modifier-item">
|
||||
<label class="switch-row modifier-switch">
|
||||
<input type="checkbox" v-model="sys.config.useGravity" />
|
||||
<span class="switch-ui"></span><span>开启重力 (Gravity)</span>
|
||||
</label>
|
||||
<div v-if="sys.config.useGravity" class="modifier-params gravity-params">
|
||||
<div class="modifier-mode-row">
|
||||
<button class="axis-mode" :class="{ on: sys.config.gravityMode === 'fixed' }" @click="sys.config.gravityMode = 'fixed'">固定</button>
|
||||
<button class="axis-mode" :class="{ on: sys.config.gravityMode === 'random' }" @click="sys.config.gravityMode = 'random'">随机</button>
|
||||
</div>
|
||||
<NumSlider v-if="sys.config.gravityMode === 'fixed'" label="重力强度" :min="-3000" :max="3000" :step="10" v-model="sys.config.gravityMin" />
|
||||
<template v-else>
|
||||
<NumSlider label="强度 min" :min="-3000" :max="3000" :step="10" v-model="sys.config.gravityMin" />
|
||||
<NumSlider label="强度 max" :min="-3000" :max="3000" :step="10" v-model="sys.config.gravityMax" />
|
||||
</template>
|
||||
<NumSlider label="方向" :min="-360" :max="360" :step="1" v-model="sys.config.gravityDirection" />
|
||||
<div class="gravity-curve-title"><span>生命周期内重力</span><span>纵轴:强度 0~1</span></div>
|
||||
<CurveEditor v-model="sys.config.gravityCurve" :default-value="FLAT_CURVE" />
|
||||
<div class="gravity-curve-axis"><span>0</span><span>生命周期 →</span><span>1</span></div>
|
||||
<div class="modifier-hint">0° 向右 · -90° 向下 · 负强度会反转方向</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modifier-item">
|
||||
<label class="switch-row modifier-switch">
|
||||
<input type="checkbox" v-model="sys.config.wind" />
|
||||
<span class="switch-ui"></span><span>开启风力 (Wind)</span>
|
||||
</label>
|
||||
<div v-if="sys.config.wind" class="modifier-params gravity-params">
|
||||
<div class="modifier-mode-row">
|
||||
<button class="axis-mode" :class="{ on: sys.config.windMode === 'fixed' }" @click="sys.config.windMode = 'fixed'">固定</button>
|
||||
<button class="axis-mode" :class="{ on: sys.config.windMode === 'random' }" @click="sys.config.windMode = 'random'">随机</button>
|
||||
</div>
|
||||
<NumSlider v-if="sys.config.windMode === 'fixed'" label="风力强度" :min="-3000" :max="3000" :step="10" v-model="sys.config.windMin" />
|
||||
<template v-else>
|
||||
<NumSlider label="强度 min" :min="-3000" :max="3000" :step="10" v-model="sys.config.windMin" />
|
||||
<NumSlider label="强度 max" :min="-3000" :max="3000" :step="10" v-model="sys.config.windMax" />
|
||||
</template>
|
||||
<NumSlider label="方向" :min="-360" :max="360" :step="1" v-model="sys.config.windDirection" />
|
||||
<div class="gravity-curve-title"><span>生命周期内风力</span><span>纵轴:强度 0~1</span></div>
|
||||
<CurveEditor v-model="sys.config.windCurve" :default-value="FLAT_CURVE" />
|
||||
<div class="gravity-curve-axis"><span>0</span><span>生命周期 →</span><span>1</span></div>
|
||||
<div class="modifier-hint">0° 向右 · -90° 向下 · 负强度会反转方向</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modifier-item">
|
||||
<label class="switch-row modifier-switch">
|
||||
<input type="checkbox" v-model="sys.config.forceField" />
|
||||
<span class="switch-ui"></span><span>开启力场 (Force Field)</span>
|
||||
</label>
|
||||
<div v-if="sys.config.forceField" class="modifier-params">
|
||||
<NumSlider label="中心 X" :min="-5000" :max="5000" :step="1" v-model="sys.config.forceCenterX" />
|
||||
<NumSlider label="中心 Y" :min="-5000" :max="5000" :step="1" v-model="sys.config.forceCenterY" />
|
||||
<NumSlider label="力场强度" :min="-300" :max="300" :step="1" v-model="sys.config.forceStrength" />
|
||||
<NumSlider label="力场半径" :min="1" :max="2000" :step="1" v-model="sys.config.forceRadius" />
|
||||
<label class="row"><span>衰减模式</span>
|
||||
<select v-model="sys.config.forceFalloff" class="inp">
|
||||
<option value="constant">恒定(范围内力度不变)</option>
|
||||
<option value="linear">线性衰减(边缘为 0)</option>
|
||||
<option value="inverse">距离反比(中心最强)</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="modifier-hint">正数吸向中心 · 负数向外排斥 · 可在画布内拖动力场圆</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modifier-item">
|
||||
<label class="switch-row modifier-switch">
|
||||
<input type="checkbox" v-model="sys.config.attraction" />
|
||||
<span class="switch-ui"></span><span>开启吸附 (Attraction)</span>
|
||||
</label>
|
||||
<div v-if="sys.config.attraction" class="modifier-params">
|
||||
<NumSlider label="吸附点 X" :min="-5000" :max="5000" :step="1" v-model="sys.config.attractionCenterX" />
|
||||
<NumSlider label="吸附点 Y" :min="-5000" :max="5000" :step="1" v-model="sys.config.attractionCenterY" />
|
||||
<NumSlider label="吸附半径" :min="0" :max="2000" :step="1" v-model="sys.config.attractionRadius" />
|
||||
<NumSlider label="吸附时长" :min="0.05" :max="30" :step="0.05" v-model="sys.config.attractionDuration" />
|
||||
<NumSlider label="延迟时间" :min="0" :max="30" :step="0.05" v-model="sys.config.attractionDelay" />
|
||||
<label class="row"><span>延迟方式</span>
|
||||
<select v-model="sys.config.attractionDelayMode" class="inp">
|
||||
<option value="global">统一时间</option>
|
||||
<option value="particle">单粒子生命</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="row"><span>吸附方式</span>
|
||||
<select v-model="sys.config.attractionPath" class="inp">
|
||||
<option value="linear">直线</option>
|
||||
<option value="curve">曲线</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="modifier-hint">延迟后从当前位置开始吸附 · 到达红色范围即死亡 · 可在画布内拖动吸附圆</div>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
@@ -416,6 +504,9 @@ import ParticleAttributeControl from './ParticleAttributeControl.vue'
|
||||
import ColorGradientEditor from './ColorGradientEditor.vue'
|
||||
|
||||
const store = useParticleStore()
|
||||
// 默认场景始终有一个可编辑的粒子系统;热更新保留现有系统时不重复创建。
|
||||
if (!store.systems.length) store.addSystem()
|
||||
|
||||
const activeIdx = computed(() => {
|
||||
const i = store.systems.findIndex((s) => s.id === store.activeId)
|
||||
return i >= 0 ? i : 0
|
||||
@@ -439,7 +530,13 @@ const ALPHA_CURVE = [{ x: 0, y: 0 }, { x: 0.2, y: 1 }, { x: 0.8, y: 1 }, { x: 1,
|
||||
const RESOURCE_ALPHA_CURVE = [{ x: 0, y: 0 }, { x: 0.1, y: 1 }, { x: 0.9, y: 1 }, { x: 1, y: 0 }]
|
||||
|
||||
const collapsed = reactive<Record<string, boolean>>({
|
||||
scene: false, emitMode: false, shape: false, attr: false, look: false, mods: false, export: false,
|
||||
scene: false,
|
||||
emitMode: true,
|
||||
shape: true,
|
||||
attr: true,
|
||||
look: true,
|
||||
mods: true,
|
||||
export: true,
|
||||
})
|
||||
function toggle(key: string) { collapsed[key] = !collapsed[key] }
|
||||
|
||||
@@ -788,4 +885,17 @@ function toggleResourceLock(resourceId: number) {
|
||||
.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; }
|
||||
|
||||
/* 修改器 */
|
||||
.modifier-item { padding: 8px 0 10px; border-bottom: 1px solid #2a3549; }
|
||||
.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; }
|
||||
.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; }
|
||||
.gravity-curve-axis { display: flex; justify-content: space-between; margin: 3px 34px 0 1px; color: #5f6b82; font-size: 9px; }
|
||||
.gravity-params :deep(.ce-svg) { height: 96px; background: #0f1625; border-color: #30405a; }
|
||||
</style>
|
||||
@@ -83,6 +83,8 @@ let world: Container | null = null
|
||||
let originGfx: Graphics | null = null
|
||||
let boneGfx: Graphics | null = null
|
||||
let shapeGfx: Graphics | null = null
|
||||
let forceFieldGfx: Graphics | null = null
|
||||
let attractionGfx: Graphics | null = null
|
||||
let gizmoGfx: Graphics | null = null
|
||||
let axisGfx: Graphics | null = null
|
||||
let axisLabelLayer: Container | null = null // 文字图层,不随 world 缩放,避免放大变糊
|
||||
@@ -110,6 +112,10 @@ function createLayers() {
|
||||
world.addChild(originGfx)
|
||||
shapeGfx = new Graphics() // 发射器形状范围(绿圆/蓝矩形/锥形)
|
||||
world.addChild(shapeGfx)
|
||||
forceFieldGfx = new Graphics() // 选中系统的全局力场范围
|
||||
world.addChild(forceFieldGfx)
|
||||
attractionGfx = new Graphics() // 选中系统的全局吸附范围
|
||||
world.addChild(attractionGfx)
|
||||
gizmoGfx = new Graphics() // root 变换 gizmo 手柄
|
||||
world.addChild(gizmoGfx)
|
||||
boneGfx = new Graphics()
|
||||
@@ -234,6 +240,10 @@ function onWheel(e: WheelEvent) {
|
||||
// 鼠标左键长按拖动画布(平移)
|
||||
function onPanDown(e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
// 吸附点优先于力场;重叠时拖动红色吸附圆。
|
||||
if (onAttractionDown(e)) return
|
||||
// 力场使用全局坐标,按住圆内任意位置时优先拖动力场中心。
|
||||
if (onForceFieldDown(e)) return
|
||||
// gizmo 工具激活且选中系统 → 拖拽根节点变换;否则画布平移
|
||||
if (activeTool.value && store.systems.length) {
|
||||
onGizmoDown(e)
|
||||
@@ -253,6 +263,76 @@ function onPanDown(e: PointerEvent) {
|
||||
window.addEventListener('pointercancel', up)
|
||||
}
|
||||
|
||||
function pointerToWorldMath(clientX: number, clientY: number) {
|
||||
const rect = holder.value!.getBoundingClientRect()
|
||||
const scale = Math.max(0.0001, store.editor.viewScale)
|
||||
const screenX = clientX - rect.left
|
||||
const screenY = clientY - rect.top
|
||||
return {
|
||||
x: (screenX - app!.screen.width / 2 - store.editor.viewX) / scale,
|
||||
y: -(screenY - app!.screen.height * 0.5 - store.editor.viewY) / scale,
|
||||
}
|
||||
}
|
||||
|
||||
/** 命中当前选中系统的力场圆时开始拖拽;返回 true 表示事件已被力场接管。 */
|
||||
function onForceFieldDown(e: PointerEvent) {
|
||||
const sys = store.systems.find((item) => item.id === store.activeId)
|
||||
const cfg = sys?.config
|
||||
if (!cfg?.forceField || cfg.forceRadius <= 0) return false
|
||||
const startPointer = pointerToWorldMath(e.clientX, e.clientY)
|
||||
const distance = Math.hypot(startPointer.x - cfg.forceCenterX, startPointer.y - cfg.forceCenterY)
|
||||
if (distance > cfg.forceRadius) return false
|
||||
const startX = cfg.forceCenterX
|
||||
const startY = cfg.forceCenterY
|
||||
if (holder.value) holder.value.style.cursor = 'grabbing'
|
||||
const move = (event: PointerEvent) => {
|
||||
const pointer = pointerToWorldMath(event.clientX, event.clientY)
|
||||
cfg.forceCenterX = startX + pointer.x - startPointer.x
|
||||
cfg.forceCenterY = startY + pointer.y - startPointer.y
|
||||
}
|
||||
const up = () => {
|
||||
if (holder.value) holder.value.style.cursor = ''
|
||||
window.removeEventListener('pointermove', move)
|
||||
window.removeEventListener('pointerup', up)
|
||||
window.removeEventListener('pointercancel', up)
|
||||
}
|
||||
window.addEventListener('pointermove', move)
|
||||
window.addEventListener('pointerup', up)
|
||||
window.addEventListener('pointercancel', up)
|
||||
e.preventDefault()
|
||||
return true
|
||||
}
|
||||
|
||||
/** 命中吸附圆(半径为 0 时使用屏幕 12px 热区)时拖动全局吸附点。 */
|
||||
function onAttractionDown(e: PointerEvent) {
|
||||
const sys = store.systems.find((item) => item.id === store.activeId)
|
||||
const cfg = sys?.config
|
||||
if (!cfg?.attraction) return false
|
||||
const startPointer = pointerToWorldMath(e.clientX, e.clientY)
|
||||
const hitRadius = Math.max(cfg.attractionRadius, 12 / Math.max(0.0001, store.editor.viewScale))
|
||||
const distance = Math.hypot(startPointer.x - cfg.attractionCenterX, startPointer.y - cfg.attractionCenterY)
|
||||
if (distance > hitRadius) return false
|
||||
const startX = cfg.attractionCenterX
|
||||
const startY = cfg.attractionCenterY
|
||||
if (holder.value) holder.value.style.cursor = 'grabbing'
|
||||
const move = (event: PointerEvent) => {
|
||||
const pointer = pointerToWorldMath(event.clientX, event.clientY)
|
||||
cfg.attractionCenterX = startX + pointer.x - startPointer.x
|
||||
cfg.attractionCenterY = startY + pointer.y - startPointer.y
|
||||
}
|
||||
const up = () => {
|
||||
if (holder.value) holder.value.style.cursor = ''
|
||||
window.removeEventListener('pointermove', move)
|
||||
window.removeEventListener('pointerup', up)
|
||||
window.removeEventListener('pointercancel', up)
|
||||
}
|
||||
window.addEventListener('pointermove', move)
|
||||
window.addEventListener('pointerup', up)
|
||||
window.addEventListener('pointercancel', up)
|
||||
e.preventDefault()
|
||||
return true
|
||||
}
|
||||
|
||||
// root gizmo 拖拽:位移 / 旋转 / 缩放(改当前选中系统的 config,由 loop 实时应用)
|
||||
function onGizmoDown(e: PointerEvent) {
|
||||
const sys = store.systems.find((s) => s.id === store.activeId)
|
||||
@@ -413,6 +493,55 @@ function drawShapeRange() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 绘制当前选中系统的全局力场;不应用粒子系统 root 变换。 */
|
||||
function drawForceField() {
|
||||
if (!forceFieldGfx) return
|
||||
const g = forceFieldGfx
|
||||
g.clear()
|
||||
const sys = store.systems.find((item) => item.id === store.activeId)
|
||||
const cfg = sys?.config
|
||||
if (!cfg?.forceField || cfg.forceRadius <= 0) return
|
||||
const x = cfg.forceCenterX
|
||||
const y = -cfg.forceCenterY
|
||||
const color = cfg.forceStrength >= 0 ? 0x58c7ff : 0xff6b7a
|
||||
g.lineStyle(2, color, 0.85)
|
||||
g.beginFill(color, 0.08)
|
||||
g.drawCircle(x, y, cfg.forceRadius)
|
||||
g.endFill()
|
||||
g.lineStyle(1, color, 0.7)
|
||||
g.moveTo(x - 9, y); g.lineTo(x + 9, y)
|
||||
g.moveTo(x, y - 9); g.lineTo(x, y + 9)
|
||||
g.beginFill(color, 0.95)
|
||||
g.drawCircle(x, y, 4)
|
||||
g.endFill()
|
||||
}
|
||||
|
||||
/** 绘制全局吸附点与吸附完成半径;半径为 0 时显示为红色点。 */
|
||||
function drawAttraction() {
|
||||
if (!attractionGfx) return
|
||||
const g = attractionGfx
|
||||
g.clear()
|
||||
const sys = store.systems.find((item) => item.id === store.activeId)
|
||||
const cfg = sys?.config
|
||||
if (!cfg?.attraction) return
|
||||
const x = cfg.attractionCenterX
|
||||
const y = -cfg.attractionCenterY
|
||||
const radius = Math.max(0, cfg.attractionRadius)
|
||||
const color = 0xff4d5f
|
||||
if (radius > 0) {
|
||||
g.lineStyle(2, color, 0.95)
|
||||
g.beginFill(color, 0.09)
|
||||
g.drawCircle(x, y, radius)
|
||||
g.endFill()
|
||||
}
|
||||
g.lineStyle(1, color, 0.85)
|
||||
g.moveTo(x - 10, y); g.lineTo(x + 10, y)
|
||||
g.moveTo(x, y - 10); g.lineTo(x, y + 10)
|
||||
g.beginFill(color, 1)
|
||||
g.drawCircle(x, y, radius > 0 ? 4 : 6)
|
||||
g.endFill()
|
||||
}
|
||||
|
||||
// 根变换 gizmo 手柄:当前选中系统,按 activeTool 绘制(位移/旋转/缩放)。中心 = root 位置。
|
||||
// 手柄几何在 root 局部坐标系定义(原点 = root 中心,ly 数学上正),经 root 缩放+旋转+平移到 root 中心,
|
||||
// 与 drawShapeRange 的变换一致 → 手柄随 root 旋转/缩放/位移实时变化。
|
||||
@@ -569,6 +698,8 @@ function loop() {
|
||||
}
|
||||
drawOrigin()
|
||||
drawShapeRange()
|
||||
drawForceField()
|
||||
drawAttraction()
|
||||
drawGizmo()
|
||||
drawBones(collected)
|
||||
if (info.value) { info.value.systems = store.systems.length; info.value.particles = total }
|
||||
|
||||
Reference in new issue
Block a user