系统优化
This commit is contained in:
@@ -98,7 +98,7 @@ export interface EmitterConfig {
|
||||
burstLoop: boolean // 爆发是否每秒重复
|
||||
/** 延迟发射(秒)= 粒子条左端位置 */
|
||||
delay: number
|
||||
/** 发射总时长(秒)= 粒子条宽度,超过后停止发射新粒子 */
|
||||
/** 持续发射时长(秒)= 时间轴绿色段;爆发模式不使用该时长 */
|
||||
duration: number
|
||||
shape: EmitShape // 发射形状
|
||||
radius: number
|
||||
@@ -346,7 +346,7 @@ export function defaultConfig(): EmitterConfig {
|
||||
burstCount: 50,
|
||||
burstLoop: false, // 一次性爆发(持续爆发开关已移除)
|
||||
delay: 0,
|
||||
duration: 0, // 0 = 无限发射/爆发,不受时长限制(发射时长 UI 已移除)
|
||||
duration: 1, // 持续发射时长;默认 1 秒(30f)。爆发模式运行时按 0 处理。
|
||||
shape: 'point',
|
||||
radius: 30,
|
||||
rectW: 60,
|
||||
@@ -858,7 +858,8 @@ export class ParticleEmitter extends Container {
|
||||
}
|
||||
this._attractionWasEnabled = cfg.attraction
|
||||
const inWindow = cfg.delay <= 0 || this._elapsed >= cfg.delay
|
||||
const withinDuration = cfg.duration <= 0 || this._elapsed < cfg.delay + cfg.duration
|
||||
// 发射时长只属于持续模式;爆发模式在延迟结束时执行一次,不显示也不消耗持续段。
|
||||
const withinDuration = cfg.mode === 'burst' || this._elapsed < cfg.delay + Math.max(1 / 30, cfg.duration)
|
||||
// 发射(仅在窗口内且未超时长)
|
||||
if (inWindow && withinDuration) {
|
||||
if (cfg.mode === 'stream') {
|
||||
|
||||
@@ -80,7 +80,8 @@ export const useParticleStore = defineStore('particle', {
|
||||
activeId: 0,
|
||||
activeObjectType: 'particle' as SceneObjectType,
|
||||
activeObjectId: 0,
|
||||
timeline: { playing: true, loop: true, frame: 0, totalFrames: 120, fps: 60, recorded: false, animation: 'animation' } as TimelineState,
|
||||
// 与 Spine 编辑器默认时间基准对齐:1 秒 = 30 帧。
|
||||
timeline: { playing: true, loop: true, frame: 0, totalFrames: 60, fps: 30, recorded: false, animation: 'animation' } as TimelineState,
|
||||
settings: { tickEnabled: false, axisFontSize: 12, tickWidth: 1, showAxisLabels: true, axisColor: '#7a86ad' } as SettingsState,
|
||||
}),
|
||||
getters: {
|
||||
@@ -203,10 +204,10 @@ export const useParticleStore = defineStore('particle', {
|
||||
},
|
||||
/** 依据所有粒子系统的"最晚消失时间"重算总帧数。
|
||||
* 固定生命周期使用 lifeMin;随机生命周期使用上下限中的较大值。
|
||||
* 单系统总时长 = delay(延迟) + duration(发射时长) + 最大粒子生命,
|
||||
* 单系统总时长 = delay + 持续模式 duration(爆发为 0)+ 最大粒子生命,
|
||||
* 多系统取最大;转帧 = ceil(sec * fps)。 */
|
||||
recalcTotalFrames() {
|
||||
const fps = this.timeline.fps || 60
|
||||
const fps = this.timeline.fps || 30
|
||||
let maxSec = 0
|
||||
for (const sys of this.systems) {
|
||||
const c = sys.config
|
||||
@@ -215,7 +216,8 @@ export const useParticleStore = defineStore('particle', {
|
||||
const particleLife = c.lifeMode === 'fixed'
|
||||
? lifeMin
|
||||
: Math.max(lifeMin, lifeMax)
|
||||
const end = Math.max(0, Number(c.delay) || 0) + Math.max(0, Number(c.duration) || 0) + particleLife
|
||||
const emissionDuration = c.mode === 'stream' ? Math.max(1 / fps, Number(c.duration) || 0) : 0
|
||||
const end = Math.max(0, Number(c.delay) || 0) + emissionDuration + particleLife
|
||||
if (end > maxSec) maxSec = end
|
||||
}
|
||||
const frames = Math.max(1, Math.ceil(maxSec * fps))
|
||||
|
||||
@@ -138,13 +138,17 @@
|
||||
<div class="group">
|
||||
<div class="group-head" @click="toggle('emitMode')">
|
||||
<span class="caret">{{ collapsed.emitMode ? '▸' : '▾' }}</span><span>发射模式</span>
|
||||
<button class="group-reset" title="重置发射模式参数" @click.stop="resetModule('emitMode')">重置</button>
|
||||
</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>
|
||||
<button class="seg" :class="{ on: sys.config.mode === 'stream' }" @click="setEmitMode('stream')">持续</button>
|
||||
<button class="seg" :class="{ on: sys.config.mode === 'burst' }" @click="setEmitMode('burst')">爆发</button>
|
||||
</div>
|
||||
<NumSlider v-if="sys.config.mode === 'stream'" label="发射速率" :min="1" :max="300" :step="1" v-model="sys.config.rate" />
|
||||
<template v-if="sys.config.mode === 'stream'">
|
||||
<NumSlider label="发射速率" :min="1" :max="300" :step="1" v-model="sys.config.rate" />
|
||||
<NumSlider label="发射时长" :min="1 / 30" :max="60" :step="1 / 30" v-model="sys.config.duration" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<NumSlider label="单次数量" :min="1" :max="1000" :step="1" v-model="sys.config.burstCount" />
|
||||
</template>
|
||||
@@ -156,6 +160,7 @@
|
||||
<div class="group">
|
||||
<div class="group-head" @click="toggle('shape')">
|
||||
<span class="caret">{{ collapsed.shape ? '▸' : '▾' }}</span><span>发射器形状</span>
|
||||
<button class="group-reset" title="重置发射器形状参数" @click.stop="resetModule('shape')">重置</button>
|
||||
</div>
|
||||
<div class="group-body" v-show="!collapsed.shape">
|
||||
<label class="row"><span>形状</span>
|
||||
@@ -187,6 +192,7 @@
|
||||
<div class="group">
|
||||
<div class="group-head" @click="toggle('attr')">
|
||||
<span class="caret">{{ collapsed.attr ? '▸' : '▾' }}</span><span>粒子属性</span>
|
||||
<button class="group-reset" title="重置粒子属性参数" @click.stop="resetModule('attr')">重置</button>
|
||||
</div>
|
||||
<div class="group-body attr-body" v-show="!collapsed.attr">
|
||||
<div class="attr-section-title">粒子初始参数</div>
|
||||
@@ -336,6 +342,7 @@
|
||||
<div class="group">
|
||||
<div class="group-head" @click="toggle('look')">
|
||||
<span class="caret">{{ collapsed.look ? '▸' : '▾' }}</span><span>外观与资源</span>
|
||||
<button class="group-reset" title="重置外观与资源参数" @click.stop="resetModule('look')">重置</button>
|
||||
</div>
|
||||
<div class="group-body" v-show="!collapsed.look">
|
||||
<!-- 透明度 -->
|
||||
@@ -427,6 +434,7 @@
|
||||
<div class="group">
|
||||
<div class="group-head" @click="toggle('mods')">
|
||||
<span class="caret">{{ collapsed.mods ? '▸' : '▾' }}</span><span>修改器 (Modifiers)</span>
|
||||
<button class="group-reset" title="重置修改器参数" @click.stop="resetModule('mods')">重置</button>
|
||||
</div>
|
||||
<div class="group-body" v-show="!collapsed.mods">
|
||||
<div class="modifier-item">
|
||||
@@ -662,6 +670,7 @@
|
||||
<div class="group">
|
||||
<div class="group-head" @click="toggle('export')">
|
||||
<span class="caret">{{ collapsed.export ? '▸' : '▾' }}</span><span>导出设置</span><span class="tag">预留</span>
|
||||
<button class="group-reset" title="重置导出设置" @click.stop="resetModule('export')">重置</button>
|
||||
</div>
|
||||
<div class="group-body" v-show="!collapsed.export">
|
||||
<div class="placeholder">导出功能后续实现,已预留位置</div>
|
||||
@@ -675,7 +684,7 @@
|
||||
import { computed, markRaw, onUnmounted, reactive, ref, watchEffect } from 'vue'
|
||||
import { Texture } from 'pixi.js'
|
||||
import { useParticleStore } from '../store/particleStore'
|
||||
import { ensureEmitterConfig } from '../core/particleEmitter'
|
||||
import { defaultConfig, ensureEmitterConfig } from '../core/particleEmitter'
|
||||
import NumSlider from './NumSlider.vue'
|
||||
import CurveEditor from './CurveEditor.vue'
|
||||
import ParticleAttributeControl from './ParticleAttributeControl.vue'
|
||||
@@ -708,6 +717,7 @@ watchEffect(() => {
|
||||
if (config.speedMode === 'curve') config.speedMode = 'random'
|
||||
if (config.scaleMode === 'curve') config.scaleMode = 'random'
|
||||
if (config.initialRotationMode === 'curve') config.initialRotationMode = 'random'
|
||||
if (config.mode === 'stream' && (!(config.duration > 0))) config.duration = 1
|
||||
})
|
||||
|
||||
const LINEAR_CURVE = [{ x: 0, y: 0 }, { x: 1, y: 1 }]
|
||||
@@ -728,6 +738,64 @@ const collapsed = reactive<Record<string, boolean>>({
|
||||
})
|
||||
function toggle(key: string) { collapsed[key] = !collapsed[key] }
|
||||
|
||||
function setEmitMode(mode: 'stream' | 'burst') {
|
||||
const config = sys.value?.config
|
||||
if (!config) return
|
||||
config.mode = mode
|
||||
if (mode === 'stream' && (!(config.duration > 0))) config.duration = 1
|
||||
}
|
||||
|
||||
type ResetModule = 'emitMode' | 'shape' | 'attr' | 'look' | 'mods' | 'export'
|
||||
|
||||
const MODULE_RESET_KEYS: Record<Exclude<ResetModule, 'export'>, string[]> = {
|
||||
emitMode: ['mode', 'rate', 'burstCount', 'burstLoop', 'delay', 'duration'],
|
||||
shape: ['shape', 'radius', 'rectW', 'rectH', 'direction', 'spread', 'coneAngle'],
|
||||
attr: [
|
||||
'lifeMin', 'lifeMax', 'lifeMode', 'lifeCurve',
|
||||
'speedMin', 'speedMax', 'speedMode', 'speedCurve',
|
||||
'scaleMin', 'scaleMax', 'scaleMode', 'scaleAxisMode', 'scaleYMin', 'scaleYMax',
|
||||
'scaleCurve', 'scaleYCurve', 'scaleEndRatio',
|
||||
'initialRotationMode', 'initialRotationMin', 'initialRotationMax', 'initialRotationCurve',
|
||||
'rotationSpeedMin', 'rotationSpeedMax',
|
||||
'speedOverLifeEnabled', 'speedOverLifeMode', 'speedOverLifeMin', 'speedOverLifeMax', 'speedOverLifeCurve',
|
||||
'scaleOverLifeEnabled', 'scaleOverLifeMode', 'scaleOverLifeAxisMode',
|
||||
'scaleOverLifeMin', 'scaleOverLifeMax', 'scaleYOverLifeMin', 'scaleYOverLifeMax',
|
||||
'scaleOverLifeCurve', 'scaleYOverLifeCurve',
|
||||
'rotationOverLifeEnabled', 'rotationOverLifeMode', 'rotationOverLifeCurve',
|
||||
],
|
||||
look: [
|
||||
'texture', 'imageResources', 'imageWeightVersion',
|
||||
'alpha', 'alphaEnd', 'colorStart', 'colorEnd', 'colorOverLife', 'blend',
|
||||
'alphaMode', 'curveMode', 'alphaCurve', 'textureName', 'textureFolder',
|
||||
'alphaScale', 'anchorX', 'anchorY', 'independentAlpha',
|
||||
],
|
||||
mods: [
|
||||
'noise', 'noiseAmt', 'noiseRange', 'noiseSpeed',
|
||||
'useGravity', 'gravity', 'gravityMode', 'gravityMin', 'gravityMax', 'gravityDirection', 'gravityCurve',
|
||||
'wind', 'windX', 'windY', 'windMode', 'windMin', 'windMax', 'windDirection', 'windCurve',
|
||||
'forceField', 'forceStrength', 'forceRadius', 'forceCenterX', 'forceCenterY', 'forceFalloff',
|
||||
'attraction', 'attractionCenterX', 'attractionCenterY', 'attractionRadius',
|
||||
'attractionDuration', 'attractionDelay', 'attractionDelayMode', 'attractionPath',
|
||||
'trail', 'trailConfigVersion', 'trailResources', 'trailResourceVersion',
|
||||
'trailTexture', 'trailTextureName', 'trailPreviewUrl', 'trailColorMode', 'trailColor',
|
||||
'trailColorGradient', 'trailAlphaMode', 'trailAlpha', 'trailAlphaCurve',
|
||||
'trailBoneCount', 'trailLength', 'trailWidth', 'trailWidthMode',
|
||||
'trailLifeLengthEnabled', 'trailLifeLengthCurve', 'trailShapeEnabled', 'trailShapeCurve',
|
||||
'trailGridRows', 'trailGridCols', 'collision', 'pathFollow',
|
||||
'emitterFollow', 'emitterFollowMode', 'emitterFollowPathId', 'emitterAngleMode',
|
||||
'emitterFollowCurve', 'emitterFollowDuration', 'emitterFollowSpace',
|
||||
'emitterFollowDirection', 'emitterFollowOffset', 'damping',
|
||||
],
|
||||
}
|
||||
|
||||
function resetModule(module: ResetModule) {
|
||||
const config = sys.value?.config as any
|
||||
if (!config || module === 'export') return
|
||||
const defaults = defaultConfig() as any
|
||||
for (const key of MODULE_RESET_KEYS[module]) config[key] = defaults[key]
|
||||
// 重置资源模块后,Stage 会在下一帧重新挂载默认 star/trail 纹理。
|
||||
}
|
||||
|
||||
function select(i: number) { store.selectSystem(store.systems[i]?.id ?? 0) }
|
||||
function addSys() { store.addSystem() }
|
||||
function addCollider() { store.addCollider() }
|
||||
@@ -1203,6 +1271,11 @@ function toggleResourceLock(resourceId: number) {
|
||||
font-size: 12px; font-weight: 600; color: #ccd; background: #191924; user-select: none;
|
||||
}
|
||||
.group-head:hover { background: #20202e; }
|
||||
.group-reset {
|
||||
margin-left: auto; height: 22px; padding: 0 7px; border: 1px solid #3a465f; border-radius: 5px;
|
||||
background: #252d3d; color: #9eabc3; font-size: 10px; line-height: 20px; cursor: pointer;
|
||||
}
|
||||
.group-reset:hover { border-color: #7774ff; background: #45426f; color: #fff; }
|
||||
.scene-head { padding-right: 6px; }
|
||||
.scene-actions { margin-left: auto; display: flex; align-items: center; gap: 4px; }
|
||||
.scene-add {
|
||||
|
||||
+43
-21
@@ -8,6 +8,7 @@
|
||||
<button class="tl-btn" @click="store.toStart()" title="回到起点">⏮</button>
|
||||
<button class="tl-btn" :class="{ on: store.timeline.loop }" @click="store.toggleLoop()" title="循环">⟳</button>
|
||||
<span class="tl-frame">帧 {{ store.timeline.frame }} / {{ store.timeline.totalFrames }}</span>
|
||||
<span class="tl-legend"><i class="legend-emission"></i>持续发射 <i class="legend-life"></i>粒子存在</span>
|
||||
<label class="tl-anim">动画
|
||||
<select v-model="store.timeline.animation" class="tl-sel">
|
||||
<option value="animation">animation</option>
|
||||
@@ -42,14 +43,21 @@
|
||||
<div class="tl-lanes">
|
||||
<div v-for="(sys, i) in store.systems" :key="sys.id" class="tl-lane">
|
||||
<span class="lane-ico">●</span>
|
||||
<!-- 粒子条:left=start(delay), width=duration -->
|
||||
<!-- 粒子条:left=delay;红色底条=系统存在粒子的总时长;绿色覆盖层=持续发射时长 -->
|
||||
<div
|
||||
class="lane-bar"
|
||||
:style="{ left: barLeftPx(sys) + 'px', width: barWidthPx(sys) + 'px' }"
|
||||
@pointerdown.stop="onBarDown($event, sys)"
|
||||
>
|
||||
<span class="lane-name">{{ (sys.config as any).name || '系统 ' + (i + 1) }}</span>
|
||||
<span class="bar-handle" @pointerdown.stop="onBarResizeDown($event, sys)"></span>
|
||||
<span class="bar-life" :style="{ width: lifeWidthPx(sys) + 'px' }"></span>
|
||||
<span
|
||||
v-if="sys.config.mode === 'stream'"
|
||||
class="bar-emission"
|
||||
:style="{ width: emissionWidthPx(sys) + 'px' }"
|
||||
>
|
||||
<span class="bar-handle" title="拖动持续发射时长" @pointerdown.stop="onBarResizeDown($event, sys)"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!store.systems.length" class="tl-empty">暂无粒子系统</div>
|
||||
@@ -67,7 +75,8 @@ type SysLike = { id: number; config: any }
|
||||
const store = useParticleStore()
|
||||
const vpRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const fps = 60
|
||||
// 时间轴与固定步长模拟统一读取 Store;默认 1 秒 = 30 帧,与 Spine 对齐。
|
||||
const fps = computed(() => Math.max(1, store.timeline.fps || 30))
|
||||
/** 视口当前能显示的帧数(滚轮缩放它)。视口起点恒为第 0 帧,不滚动 */
|
||||
const viewFrames = ref(51)
|
||||
// 拖动期间只更新本地预览值,松手后再一次性写回配置。
|
||||
@@ -106,7 +115,7 @@ function barLeftPx(sys: SysLike) {
|
||||
const s = (sys.config as any)
|
||||
// 左端 = 延迟发射时长(delay);左端之前是空白=延迟
|
||||
const delay = draftDelays.value[sys.id] ?? Math.max(0, Number(s.delay) || 0)
|
||||
return (delay * fps) / pxPerF()
|
||||
return (delay * fps.value) / pxPerF()
|
||||
}
|
||||
|
||||
function maxParticleLife(sys: SysLike) {
|
||||
@@ -117,11 +126,19 @@ function maxParticleLife(sys: SysLike) {
|
||||
}
|
||||
|
||||
function barWidthPx(sys: SysLike) {
|
||||
const s = (sys.config as any)
|
||||
// 宽度 = 粒子系统总时长 = 发射时长(duration) + 当前模式下的最大粒子生命
|
||||
// 即从 delay 开始,覆盖到最后一个粒子消失,右端与 totalFrames 对齐
|
||||
const duration = draftDurations.value[sys.id] ?? Math.max(0, Number(s.duration) || 0)
|
||||
return ((duration + maxParticleLife(sys)) * fps) / pxPerF()
|
||||
return lifeWidthPx(sys)
|
||||
}
|
||||
|
||||
function emissionWidthPx(sys: SysLike) {
|
||||
if ((sys.config as any).mode !== 'stream') return 0
|
||||
const duration = draftDurations.value[sys.id] ?? Math.max(1 / fps.value, Number((sys.config as any).duration) || 1)
|
||||
return (duration * fps.value) / pxPerF()
|
||||
}
|
||||
|
||||
function lifeWidthPx(sys: SysLike) {
|
||||
// 红色底条表示整个系统“仍有粒子存在”的时长:持续发射窗口 + 最长粒子生命。
|
||||
// 爆发模式的发射窗口为 0,因此只保留最长粒子生命。
|
||||
return emissionWidthPx(sys) + (maxParticleLife(sys) * fps.value) / pxPerF()
|
||||
}
|
||||
|
||||
// 滚轮缩放:只改变视口显示的帧数(左侧恒为 0 帧),不改变播放时长/totalFrames
|
||||
@@ -152,11 +169,11 @@ function onBarDown(e: PointerEvent, sys: SysLike) {
|
||||
e.stopPropagation()
|
||||
const startX = e.clientX
|
||||
const startDelay = Math.max(0, Number((sys.config as any).delay) || 0)
|
||||
const secPerPx = pxPerF() / fps
|
||||
const secPerPx = pxPerF() / fps.value
|
||||
const move = (ev: PointerEvent) => {
|
||||
const dSec = (ev.clientX - startX) * secPerPx
|
||||
const nd = Math.max(0, Math.min(startDelay + dSec, 600))
|
||||
draftDelays.value[sys.id] = Math.round(nd * fps) / fps
|
||||
draftDelays.value[sys.id] = Math.round(nd * fps.value) / fps.value
|
||||
}
|
||||
const finish = (commit: boolean) => {
|
||||
window.removeEventListener('pointermove', move)
|
||||
@@ -176,17 +193,16 @@ function onBarDown(e: PointerEvent, sys: SysLike) {
|
||||
// 拖动粒子条右缘:调整发射时长 duration
|
||||
function onBarResizeDown(e: PointerEvent, sys: SysLike) {
|
||||
e.stopPropagation()
|
||||
const startX = e.clientX
|
||||
const startDur = Math.max(0, Number((sys.config as any).duration) || 0)
|
||||
const secPerPx = pxPerF() / fps
|
||||
const secPerPx = pxPerF() / fps.value
|
||||
const barLeft = (e.currentTarget as HTMLElement).closest('.lane-bar')?.getBoundingClientRect().left ?? e.clientX
|
||||
let moved = false
|
||||
const move = (ev: PointerEvent) => {
|
||||
moved = true
|
||||
const dSec = (ev.clientX - startX) * secPerPx
|
||||
let nd = Math.max(0, Math.min(startDur + dSec, 600))
|
||||
// 有限发射最短为 1 帧;0 继续保留“无限发射”的既有语义。
|
||||
if (nd > 0) nd = Math.max(1 / fps, nd)
|
||||
draftDurations.value[sys.id] = Math.round(nd * fps) / fps
|
||||
// 使用右端相对粒子条左端的绝对距离换算发射时长。
|
||||
// 因此拖到 60f 就得到 60f,而不是减去粒子生命周期后的剩余帧数。
|
||||
const nd = Math.max(1 / fps.value, Math.min((ev.clientX - barLeft) * secPerPx, 600))
|
||||
draftDurations.value[sys.id] = Math.round(nd * fps.value) / fps.value
|
||||
}
|
||||
const finish = (commit: boolean) => {
|
||||
window.removeEventListener('pointermove', move)
|
||||
@@ -210,6 +226,10 @@ function onBarResizeDown(e: PointerEvent, sys: SysLike) {
|
||||
.tl-btn { width: 30px; height: 26px; background: #1e1e2e; border: 1px solid #2e2e44; border-radius: 6px; color: #aab; font-size: 12px; cursor: pointer; }
|
||||
.tl-btn.on { background: #3a5a8c; color: #fff; border-color: #5a7ab8; }
|
||||
.tl-frame { font-size: 12px; color: #aab; }
|
||||
.tl-legend { display: flex; align-items: center; gap: 4px; color: #778198; font-size: 10px; }
|
||||
.tl-legend i { display: inline-block; width: 10px; height: 7px; border-radius: 2px; }
|
||||
.legend-emission { margin-left: 4px; background: #2f8b62; }
|
||||
.legend-life { margin-left: 5px; background: #a54551; }
|
||||
.tl-anim { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #aab; }
|
||||
.tl-sel { background: #1a1a28; border: 1px solid #2e2e44; color: #dde; border-radius: 5px; padding: 2px 6px; font-size: 12px; }
|
||||
.tl-zoom { margin-left: auto; font-size: 11px; color: #667; }
|
||||
@@ -221,10 +241,12 @@ function onBarResizeDown(e: PointerEvent, sys: SysLike) {
|
||||
.tl-lanes { position: absolute; top: 22px; left: 0; right: 0; bottom: 0; }
|
||||
.tl-lane { position: relative; height: 26px; margin-bottom: 4px; }
|
||||
.lane-ico { position: absolute; left: 4px; top: 6px; font-size: 11px; color: #dde; z-index: 2; pointer-events: none; }
|
||||
.lane-bar { position: absolute; top: 4px; height: 18px; background: #2b4a8c; border: 1px solid #4a6aa8; border-radius: 5px; display: flex; align-items: center; padding-left: 14px; cursor: grab; box-sizing: border-box; }
|
||||
.lane-bar { position: absolute; top: 4px; height: 18px; display: flex; align-items: stretch; cursor: grab; box-sizing: border-box; }
|
||||
.lane-bar:active { cursor: grabbing; }
|
||||
.lane-name { font-size: 11px; color: #dfe; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.bar-handle { position: absolute; right: 0; top: 0; bottom: 0; width: 8px; cursor: ew-resize; background: rgba(255,255,255,0.15); border-radius: 0 4px 4px 0; }
|
||||
.lane-name { position: absolute; left: 14px; right: 5px; top: 2px; z-index: 2; pointer-events: none; font-size: 11px; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.bar-life { position: absolute; inset: 0 auto 0 0; z-index: 0; min-width: 1px; border: 1px solid #c05a66; border-radius: 5px; background: #a54551; box-sizing: border-box; }
|
||||
.bar-emission { position: absolute; inset: 0 auto 0 0; z-index: 1; min-width: 1px; border: 1px solid #45a97d; border-radius: 5px 0 0 5px; background: #2f8b62; box-sizing: border-box; }
|
||||
.bar-handle { position: absolute; z-index: 4; right: -4px; top: -1px; bottom: -1px; width: 8px; cursor: ew-resize; background: rgba(255,255,255,0.18); }
|
||||
.bar-handle:hover { background: rgba(255,255,255,0.35); }
|
||||
.tl-empty { color: #667; font-size: 11px; padding: 8px; }
|
||||
</style>
|
||||
|
||||
+359
@@ -0,0 +1,359 @@
|
||||
# 网页粒子系统 · 阶段六
|
||||
|
||||
> 阶段定位:**场景对象、碰撞体、路径编辑与发射器路径跟随定版**
|
||||
> 完成日期:2026-08-29
|
||||
> 工程目录:`/Users/tianmokeji/Desktop/SpineParticle`
|
||||
> 技术栈:Vue 3 + TypeScript + Pinia + PixiJS 7 + Vite
|
||||
|
||||
---
|
||||
|
||||
## 一、阶段结论
|
||||
|
||||
阶段六完成了碰撞功能所需的场景对象基础,并把粒子系统、碰撞体和路径统一纳入同一套场景对象树。碰撞体已经参与粒子运行时计算;路径具备可视化锚点、贝塞尔手柄和预设形状编辑能力;粒子系统新增发射器路径跟随修改器。
|
||||
|
||||
本阶段主要完成:
|
||||
|
||||
1. 统一场景对象创建入口和对象列表;
|
||||
2. 新增碰撞体对象、属性面板、画布显示和实时碰撞响应;
|
||||
3. 新增路径对象、锚点及贝塞尔手柄编辑;
|
||||
4. 新增圆形、方形、多边形和星形路径预设;
|
||||
5. 新增发射器路径跟随修改器;
|
||||
6. 支持局部空间和世界空间两种跟随方式;
|
||||
7. 整理画布左上角系统设置入口;
|
||||
8. 修复新模块与既有粒子属性、固定帧缓存和时间轴的兼容问题;
|
||||
9. 修复时间轴粒子条拖动时反复重置模拟的问题。
|
||||
|
||||
Spine 骨架创建入口在本阶段仅保留占位,尚未创建实际骨架对象。碰撞体面板中的“跟随轨道移动”也保留为后续碰撞体动画接口,本阶段未接入路径采样。
|
||||
|
||||
---
|
||||
|
||||
## 二、场景对象统一管理
|
||||
|
||||
### 2.1 创建入口
|
||||
|
||||
原编辑器顶部的“+系统”入口已合并到“场景对象”模块。场景对象标题右侧提供:
|
||||
|
||||
- `+粒子`:创建粒子系统;
|
||||
- `+碰撞`:创建碰撞体;
|
||||
- `+路径`:创建路径;
|
||||
- `+骨架`:Spine 骨架占位入口。
|
||||
|
||||
即使场景中没有粒子系统,场景对象模块仍可独立创建碰撞体或路径。
|
||||
|
||||
### 2.2 对象树
|
||||
|
||||
创建后的对象统一显示在场景对象树中,不再保留顶部独立的粒子系统列表。对象树支持:
|
||||
|
||||
- 粒子系统、碰撞体和路径混合显示;
|
||||
- 点击对象切换当前属性面板;
|
||||
- 当前对象高亮;
|
||||
- 单独删除对象;
|
||||
- 删除当前对象后自动选择可用的回退对象。
|
||||
|
||||
默认对象名称使用英文编号:
|
||||
|
||||
| 对象类型 | 默认名称 |
|
||||
|---|---|
|
||||
| 粒子系统 | `ParticleSystem1` |
|
||||
| 碰撞体 | `Collider1` |
|
||||
| 路径 | `Path1` |
|
||||
|
||||
---
|
||||
|
||||
## 三、碰撞体对象
|
||||
|
||||
### 3.1 默认参数
|
||||
|
||||
| 参数 | 默认值 |
|
||||
|---|---:|
|
||||
| 名称 | `Collider1` |
|
||||
| 标签 | `Default` |
|
||||
| 类型 | 圆形 |
|
||||
| 启用 | 开启 |
|
||||
| X / Y | 0 / 0 |
|
||||
| 旋转 | 0° |
|
||||
| 半径 | 50 |
|
||||
| 椭圆 X / Y 半径 | 70 / 45 |
|
||||
| 矩形宽 / 高 | 120 / 80 |
|
||||
| 多边形边数 | 6 |
|
||||
| 碰撞响应 | 物理 |
|
||||
| 弹性 | 0.8 |
|
||||
| 摩擦 | 0.3 |
|
||||
| 跟随轨道移动 | 关闭,后续接入 |
|
||||
|
||||
### 3.2 类型
|
||||
|
||||
碰撞体支持五种类型:
|
||||
|
||||
1. 圆形;
|
||||
2. 椭圆;
|
||||
3. 矩形;
|
||||
4. 多边形;
|
||||
5. 自定义。
|
||||
|
||||
圆形使用半径;椭圆使用 X/Y 半径;矩形、多边形和自定义形状使用宽高,多边形与自定义形状还可设置 3~16 的边数。
|
||||
|
||||
当前“自定义”采用可调边数的规则多边形作为运行时轮廓,为后续接入自由顶点编辑保留类型。
|
||||
|
||||
### 3.3 标签
|
||||
|
||||
- 新建碰撞体默认拥有 `Default` 标签;
|
||||
- 点击标签可设为当前标签;
|
||||
- 点击 `+` 自动创建 `Tag N`;
|
||||
- 标签数据归属于各碰撞体对象。
|
||||
|
||||
本阶段标签已完成编辑和存储,尚未作为粒子碰撞过滤条件。
|
||||
|
||||
### 3.4 碰撞响应
|
||||
|
||||
| 响应 | 行为 |
|
||||
|---|---|
|
||||
| 物理(弹跳/摩擦) | 把粒子推出碰撞体,根据法线反射速度,并应用弹性和切向摩擦 |
|
||||
| 暂停(停止不动) | 粒子停留在碰撞边界,速度清零,但自身生命周期继续消耗 |
|
||||
| 消失(立即销毁) | 粒子进入碰撞体后立即死亡并回收到粒子池 |
|
||||
|
||||
弹性和摩擦限制在 `0~1`:
|
||||
|
||||
- 弹性 `1` 表示完全弹跳,`0` 表示不保留法线反弹速度;
|
||||
- 摩擦 `1` 表示停止切向滑动,`0` 表示保留切向速度。
|
||||
|
||||
### 3.5 坐标与运行时
|
||||
|
||||
碰撞体使用场景世界坐标。每帧把粒子局部位置和速度转换到世界空间完成碰撞检测,再把修正后的位置和速度变换回粒子系统局部空间。因此粒子系统经过位移、旋转、缩放或发射器路径跟随后,仍能与固定世界碰撞体正确交互。
|
||||
|
||||
原修改器面板中的全局“碰撞体开关”已移除。是否参与碰撞由每个碰撞体自己的“启用”属性控制。
|
||||
|
||||
---
|
||||
|
||||
## 四、路径对象与画布编辑
|
||||
|
||||
### 4.1 路径属性
|
||||
|
||||
每条路径独立保存:
|
||||
|
||||
- 英文名称 `PathN`;
|
||||
- 启用状态;
|
||||
- 对象位置和旋转;
|
||||
- 是否闭合;
|
||||
- 轨道颜色;
|
||||
- 锚点及入/出贝塞尔手柄;
|
||||
- 当前预设类型。
|
||||
|
||||
路径使用三次贝塞尔曲线连接相邻锚点。闭合路径会额外连接最后一个锚点与第一个锚点。
|
||||
|
||||
### 4.2 锚点操作
|
||||
|
||||
- 双击空白处或曲线:新建锚点;
|
||||
- 双击锚点:删除节点;
|
||||
- 双击第二下按住拖动:新建锚点并拉出对称手柄;
|
||||
- 拖动锚点:移动节点;
|
||||
- 拖动手柄:调整曲线;
|
||||
- `Shift + 拖动手柄`:两侧手柄长度与方向完全对称;
|
||||
- `Alt + 拖动手柄`:独立控制单侧手柄;
|
||||
- `Ctrl/Command + 点击或框选`:加选节点;
|
||||
- `Alt + 点击或框选`:减选节点;
|
||||
- `Alt + 单击空白处`:清空选择;
|
||||
- 选中多个节点后拖动:整体移动所选节点。
|
||||
|
||||
路径锚点、手柄、连线和选择状态均直接绘制在画布中。
|
||||
|
||||
### 4.3 路径预设
|
||||
|
||||
| 预设 | 生成结果 |
|
||||
|---|---|
|
||||
| 圆形 | 4 个锚点和贝塞尔手柄组成的闭合圆 |
|
||||
| 方形 | 4 个直线锚点组成的闭合方形 |
|
||||
| 多边形 | 6 个锚点组成的闭合规则多边形 |
|
||||
| 星形 | 10 个交替半径锚点组成的闭合星形 |
|
||||
|
||||
“应用预设”会替换当前路径锚点,“清空锚点”只清除节点,不删除路径对象。
|
||||
|
||||
---
|
||||
|
||||
## 五、发射器路径跟随
|
||||
|
||||
### 5.1 默认状态
|
||||
|
||||
| 参数 | 默认值 |
|
||||
|---|---:|
|
||||
| 开启发射器跟随 | 关闭 |
|
||||
| 跟随模式 | 无 |
|
||||
| 跟随轨道 | 无 |
|
||||
| 发射器角度模式 | 固定角度 |
|
||||
| 移动进度曲线 | 0→1 线性 |
|
||||
| 移动时长 | 2 秒 |
|
||||
| 跟随空间 | 世界空间 |
|
||||
| 移动方向 | 正向 |
|
||||
| 偏移起始点 | 0 |
|
||||
|
||||
开启修改器后,跟随模式仍默认为“无”;只有主动选择“跟随轨道”并指定有效路径后,发射器才开始移动。
|
||||
|
||||
### 5.2 移动进度
|
||||
|
||||
路径按实际采样长度计算进度,避免不同长度曲线段使用相同参数步长后产生明显速度差异。
|
||||
|
||||
```text
|
||||
归一化时间 = 当前时间 / 移动时长
|
||||
路径进度 = 移动进度曲线(归一化时间)
|
||||
```
|
||||
|
||||
偏移起始点限制在 `0~1`,用于跳过路径开头部分。反向模式从终点向起点采样,并反转移动方向角度。
|
||||
|
||||
### 5.3 发射器角度
|
||||
|
||||
| 模式 | 行为 |
|
||||
|---|---|
|
||||
| 固定角度 | 保持粒子系统原有根旋转 |
|
||||
| 跟随移动方向 | 根据路径当前位置前后的采样点计算切线方向 |
|
||||
|
||||
角度会继续叠加到粒子的发射方向和锥形散布计算中。
|
||||
|
||||
### 5.4 跟随空间
|
||||
|
||||
| 空间 | 行为 |
|
||||
|---|---|
|
||||
| 局部空间(GEN 移动) | 移动整个发射器容器,已经出生的粒子随发射器整体移动 |
|
||||
| 世界空间(粒子生成点移动) | 只移动新粒子的出生点,已经出生的粒子保留在原世界位置 |
|
||||
|
||||
碰撞、力场和吸附等使用世界坐标的模块同步使用当前模拟变换,避免启用路径跟随后发生坐标错位。
|
||||
|
||||
---
|
||||
|
||||
## 六、系统设置与画布辅助显示
|
||||
|
||||
原画布左上角的“发射点、骨骼连线、蒙皮网格”三个复选项已移动到系统设置面板。系统设置入口移动到画布左上角,并直接显示文字“系统设置”。
|
||||
|
||||
系统设置当前包括:
|
||||
|
||||
- 发射点显示,默认开启;
|
||||
- 骨骼连线显示,默认关闭;
|
||||
- 蒙皮网格显示,默认关闭;
|
||||
- 坐标轴刻度开关;
|
||||
- 刻度文字大小;
|
||||
- 刻度线粗细;
|
||||
- 坐标轴颜色;
|
||||
- 刻度数值显示。
|
||||
|
||||
### 6.1 模块级重置
|
||||
|
||||
以下面板标题右侧统一增加“重置”按钮:
|
||||
|
||||
- 发射模式;
|
||||
- 发射器形状;
|
||||
- 粒子属性;
|
||||
- 外观与资源;
|
||||
- 修改器;
|
||||
- 导出设置占位。
|
||||
|
||||
重置只恢复当前模块在 `defaultConfig()` 中的默认参数,不修改粒子系统名称、场景位置、旋转、缩放、随机种子或其他模块。外观与资源会恢复默认 `star.png` 资源,修改器会恢复全部关闭状态及默认 `trail.png` 拖尾资源。点击重置按钮不会改变面板当前的展开或折叠状态。
|
||||
|
||||
---
|
||||
|
||||
## 七、兼容性与时间轴修复
|
||||
|
||||
### 7.1 帧率基准
|
||||
|
||||
编辑器固定帧率统一为 **30 FPS**,与 Spine 默认时间基准对齐:
|
||||
|
||||
```text
|
||||
1 秒 = 30 帧
|
||||
固定模拟步长 = 1 / 30 秒
|
||||
```
|
||||
|
||||
时间轴显示、秒数与帧数换算、拖动吸附、总帧数计算、粒子固定步长模拟和发射器路径跟随均读取同一个帧率配置,不再单独写死 60 FPS。
|
||||
|
||||
### 7.2 粒子属性与时间轴总长度
|
||||
|
||||
时间轴总帧数根据各粒子系统最晚消失时间计算:
|
||||
|
||||
```text
|
||||
总时长 = 延迟时间 + 发射持续时间 + 当前模式下的最大粒子生命
|
||||
```
|
||||
|
||||
- 固定生命周期使用 `lifeMin`;
|
||||
- 随机生命周期使用 `lifeMin`、`lifeMax` 中的较大值;
|
||||
- 多个粒子系统取最晚结束时间。
|
||||
|
||||
修复前固定生命周期仍按 `lifeMax` 计算,时间轴可能提前循环,使生命周期、速度、缩放等参数看起来没有生效。
|
||||
|
||||
### 7.3 时间轴粒子条语义
|
||||
|
||||
时间轴粒子条使用一个延迟段和两层叠加色条:
|
||||
|
||||
1. 左侧空白:发射延迟 `delay`;
|
||||
2. 红色底条:整个粒子系统仍有粒子存在的总时长,不可直接缩放;
|
||||
3. 绿色覆盖层:持续发射时长 `duration`,仅持续模式显示,可拖动。
|
||||
|
||||
持续发射时长默认 1 秒,即 30f;拖动绿色层到 60f 表示持续发射 2 秒。持续模式下,红色底条长度为“绿色发射时长 + 最长粒子生命”,绿色覆盖红色底条的前半段,停止发射后露出的红色尾段表示剩余粒子继续存活的时间。因此红色底条一定比绿色层长。
|
||||
|
||||
爆发模式没有持续发射窗口,因此不显示绿色覆盖层,红色底条长度就是最长粒子生命。
|
||||
|
||||
红色生命段根据粒子属性模式自动计算:固定模式使用固定生命值,随机模式使用上下限中的较大值。用户修改生命参数后,红色段和总帧数同步更新。
|
||||
|
||||
拖动整个叠加条仍用于修改发射延迟。持续发射的最短时长为 1 帧。
|
||||
|
||||
### 7.4 拖动重算修复
|
||||
|
||||
修复前,鼠标每次移动都会直接写入配置,触发固定帧缓存清空和粒子模拟重置,造成粒子跳动、数量异常或短暂停止。
|
||||
|
||||
现在的处理流程为:
|
||||
|
||||
1. 按下并拖动时只更新本地条带预览;
|
||||
2. 粒子运行时保持当前模拟,不反复清空;
|
||||
3. 松开鼠标后一次性提交最终值;
|
||||
4. 固定帧缓存只重建一次;
|
||||
5. 取消拖动不修改配置;
|
||||
6. 拖长操作不再被修改前的总帧数错误限制。
|
||||
|
||||
切换持续/爆发模式不再承担“恢复异常状态”的作用;模式切换仅按当前配置正常重新模拟。
|
||||
|
||||
---
|
||||
|
||||
## 八、数据与迁移
|
||||
|
||||
粒子系统配置新增发射器跟随字段,旧配置通过 `ensureEmitterConfig()` 自动补齐默认值:
|
||||
|
||||
- `emitterFollow`;
|
||||
- `emitterFollowMode`;
|
||||
- `emitterFollowPathId`;
|
||||
- `emitterAngleMode`;
|
||||
- `emitterFollowCurve`;
|
||||
- `emitterFollowDuration`;
|
||||
- `emitterFollowSpace`;
|
||||
- `emitterFollowDirection`;
|
||||
- `emitterFollowOffset`。
|
||||
|
||||
碰撞体和路径存储在 Pinia 场景状态中,和粒子系统共同参与配置变化监听。对象、路径锚点或碰撞参数改变后会清空旧固定帧并从第 0 帧重新生成。
|
||||
|
||||
---
|
||||
|
||||
## 九、验证结果
|
||||
|
||||
本阶段完成以下验证:
|
||||
|
||||
- 粒子系统、碰撞体和路径的创建、选择、删除与英文默认命名;
|
||||
- 圆形、椭圆、矩形和规则多边形碰撞检测;
|
||||
- 物理、暂停和销毁三种碰撞响应;
|
||||
- 路径预设、锚点新增删除、多选移动和手柄调整;
|
||||
- 发射器跟随默认“无”及有效路径选择;
|
||||
- 30 FPS 时间基准,确认 1 秒对应 30 帧;
|
||||
- 正向/反向、固定角度/移动方向;
|
||||
- 局部空间和世界空间跟随;
|
||||
- 路径跟随与粒子初始生命周期、速度、缩放、旋转的组合;
|
||||
- 固定生命周期与时间轴总帧数联动;
|
||||
- 时间轴延迟拖动和持续时间缩放只在松手后重建缓存;
|
||||
- 各模块重置按钮的默认值恢复与面板展开状态保持;
|
||||
- 页面运行无可见错误;
|
||||
- `npm run build` 生产构建通过。
|
||||
|
||||
---
|
||||
|
||||
## 十、阶段七建议
|
||||
|
||||
1. 实现碰撞体自由顶点编辑,使“自定义”类型拥有独立轮廓;
|
||||
2. 把碰撞标签接入粒子系统过滤规则;
|
||||
3. 实现碰撞体跟随路径移动;
|
||||
4. 支持路径循环、往返和超出移动时长后的行为设置;
|
||||
5. 增加碰撞事件输出,为音效、子粒子或动画触发提供接口;
|
||||
6. 开始接入 Spine 骨架场景对象和骨架资源管理;
|
||||
7. 为场景对象、路径和碰撞体补充保存、导入与导出格式。
|
||||
Reference in New Issue
Block a user