系统优化

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>