系统优化

This commit is contained in:
tianmo
2026-08-29 01:59:58 +08:00
parent b98d67fdab
commit c37cf09273
5 changed files with 489 additions and 32 deletions
+4 -3
View File
@@ -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') {
+6 -4
View File
@@ -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))
+77 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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 碰撞响应
| 响应 | 行为 |
|---|---|
| 物理(弹跳/摩擦) | 把粒子推出碰撞体,根据法线反射速度,并应用弹性和切向摩擦 |
| 暂停(停止不动) | 粒子停留在碰撞边界,速度清零,但自身生命周期继续消耗 |
| 消失(立即销毁) | 粒子进入碰撞体后立即死亡并回收到粒子池 |
弹性和摩擦限制在 `01`
- 弹性 `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
归一化时间 = 当前时间 / 移动时长
路径进度 = 移动进度曲线(归一化时间)
```
偏移起始点限制在 `01`,用于跳过路径开头部分。反向模式从终点向起点采样,并反转移动方向角度。
### 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. 为场景对象、路径和碰撞体补充保存、导入与导出格式。