粒子属性调整
This commit is contained in:
+5
-5
@@ -54,13 +54,13 @@ function onTimelineDrag(e: PointerEvent) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.layout { display: flex; height: 100vh; overflow: hidden; }
|
.layout { display: flex; width: 100%; height: 100%; min-height: 0; overflow: clip; }
|
||||||
.left { flex-shrink: 0; height: 100%; overflow: hidden; }
|
.left { flex-shrink: 0; height: 100%; min-height: 0; overflow: clip; }
|
||||||
.resizer-col { width: 5px; cursor: col-resize; background: #1c1c2a; border-left: 1px solid #2a2a3c; border-right: 1px solid #2a2a3c; flex-shrink: 0; }
|
.resizer-col { width: 5px; cursor: col-resize; background: #1c1c2a; border-left: 1px solid #2a2a3c; border-right: 1px solid #2a2a3c; flex-shrink: 0; }
|
||||||
.resizer-col:hover { background: #3a5a8c; }
|
.resizer-col:hover { background: #3a5a8c; }
|
||||||
.right { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
.right { flex: 1; min-width: 0; height: 100%; min-height: 0; overflow: clip; display: flex; flex-direction: column; }
|
||||||
.stage-area { flex: 1; min-height: 0; }
|
.stage-area { flex: 1; min-height: 0; overflow: clip; }
|
||||||
.resizer-row { height: 4px; cursor: row-resize; background: #1c1c2a; border-top: 1px solid #2a2a3c; border-bottom: 1px solid #2a2a3c; flex-shrink: 0; }
|
.resizer-row { height: 4px; cursor: row-resize; background: #1c1c2a; border-top: 1px solid #2a2a3c; border-bottom: 1px solid #2a2a3c; flex-shrink: 0; }
|
||||||
.resizer-row:hover { background: #3a5a8c; }
|
.resizer-row:hover { background: #3a5a8c; }
|
||||||
.timeline-area { flex-shrink: 0; overflow: hidden; }
|
.timeline-area { flex-shrink: 0; overflow: clip; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+150
-12
@@ -6,6 +6,12 @@ export type EmitMode = 'stream' | 'burst'
|
|||||||
export type EmitShape = 'point' | 'circle' | 'rect' | 'cone' | 'orbit'
|
export type EmitShape = 'point' | 'circle' | 'rect' | 'cone' | 'orbit'
|
||||||
/** 混合模式 */
|
/** 混合模式 */
|
||||||
export type Blend = 'normal' | 'add' | 'multiply' | 'screen'
|
export type Blend = 'normal' | 'add' | 'multiply' | 'screen'
|
||||||
|
/** 粒子属性取值方式 */
|
||||||
|
export type AttributeMode = 'fixed' | 'random' | 'curve'
|
||||||
|
/** 初始旋转额外支持跟随发射方向 */
|
||||||
|
export type RotationMode = AttributeMode | 'direction'
|
||||||
|
export type ScaleAxisMode = 'uniform' | 'separate'
|
||||||
|
export interface CurvePoint { x: number; y: number }
|
||||||
|
|
||||||
/** 粒子配置(引用保持,面板改字段即实时生效) */
|
/** 粒子配置(引用保持,面板改字段即实时生效) */
|
||||||
export interface EmitterConfig {
|
export interface EmitterConfig {
|
||||||
@@ -39,15 +45,47 @@ export interface EmitterConfig {
|
|||||||
// 属性
|
// 属性
|
||||||
lifeMin: number
|
lifeMin: number
|
||||||
lifeMax: number
|
lifeMax: number
|
||||||
|
lifeMode: AttributeMode
|
||||||
|
lifeCurve: CurvePoint[]
|
||||||
speedMin: number
|
speedMin: number
|
||||||
speedMax: number
|
speedMax: number
|
||||||
|
speedMode: AttributeMode
|
||||||
|
speedCurve: CurvePoint[]
|
||||||
scaleMin: number
|
scaleMin: number
|
||||||
scaleMax: number
|
scaleMax: number
|
||||||
|
scaleMode: AttributeMode
|
||||||
|
scaleAxisMode: ScaleAxisMode
|
||||||
|
scaleYMin: number
|
||||||
|
scaleYMax: number
|
||||||
|
scaleCurve: CurvePoint[]
|
||||||
|
scaleYCurve: CurvePoint[]
|
||||||
scaleEndRatio: number
|
scaleEndRatio: number
|
||||||
|
initialRotationMode: RotationMode
|
||||||
|
initialRotationMin: number
|
||||||
|
initialRotationMax: number
|
||||||
|
initialRotationCurve: CurvePoint[]
|
||||||
alpha: number
|
alpha: number
|
||||||
alphaEnd: number
|
alphaEnd: number
|
||||||
rotationSpeedMin: number
|
rotationSpeedMin: number
|
||||||
rotationSpeedMax: number
|
rotationSpeedMax: number
|
||||||
|
// 生命周期内属性
|
||||||
|
speedOverLifeEnabled: boolean
|
||||||
|
speedOverLifeMode: AttributeMode
|
||||||
|
speedOverLifeMin: number
|
||||||
|
speedOverLifeMax: number
|
||||||
|
speedOverLifeCurve: CurvePoint[]
|
||||||
|
scaleOverLifeEnabled: boolean
|
||||||
|
scaleOverLifeMode: AttributeMode
|
||||||
|
scaleOverLifeAxisMode: ScaleAxisMode
|
||||||
|
scaleOverLifeMin: number
|
||||||
|
scaleOverLifeMax: number
|
||||||
|
scaleYOverLifeMin: number
|
||||||
|
scaleYOverLifeMax: number
|
||||||
|
scaleOverLifeCurve: CurvePoint[]
|
||||||
|
scaleYOverLifeCurve: CurvePoint[]
|
||||||
|
rotationOverLifeEnabled: boolean
|
||||||
|
rotationOverLifeMode: AttributeMode
|
||||||
|
rotationOverLifeCurve: CurvePoint[]
|
||||||
// 物理
|
// 物理
|
||||||
gravity: number
|
gravity: number
|
||||||
/** 阻尼(速度衰减系数,0=无) */
|
/** 阻尼(速度衰减系数,0=无) */
|
||||||
@@ -140,15 +178,46 @@ export function defaultConfig(): EmitterConfig {
|
|||||||
coneAngle: 60,
|
coneAngle: 60,
|
||||||
lifeMin: 0.6,
|
lifeMin: 0.6,
|
||||||
lifeMax: 1.8,
|
lifeMax: 1.8,
|
||||||
|
lifeMode: 'random',
|
||||||
|
lifeCurve: [{ x: 0, y: 0 }, { x: 1, y: 1 }],
|
||||||
speedMin: 30,
|
speedMin: 30,
|
||||||
speedMax: 140,
|
speedMax: 140,
|
||||||
|
speedMode: 'random',
|
||||||
|
speedCurve: [{ x: 0, y: 0 }, { x: 1, y: 1 }],
|
||||||
scaleMin: 0.3,
|
scaleMin: 0.3,
|
||||||
scaleMax: 0.8,
|
scaleMax: 0.8,
|
||||||
|
scaleMode: 'random',
|
||||||
|
scaleAxisMode: 'uniform',
|
||||||
|
scaleYMin: 0.3,
|
||||||
|
scaleYMax: 0.8,
|
||||||
|
scaleCurve: [{ x: 0, y: 0 }, { x: 1, y: 1 }],
|
||||||
|
scaleYCurve: [{ x: 0, y: 0 }, { x: 1, y: 1 }],
|
||||||
scaleEndRatio: 0.3,
|
scaleEndRatio: 0.3,
|
||||||
|
initialRotationMode: 'random',
|
||||||
|
initialRotationMin: 0,
|
||||||
|
initialRotationMax: 360,
|
||||||
|
initialRotationCurve: [{ x: 0, y: 0 }, { x: 1, y: 1 }],
|
||||||
alpha: 0.9,
|
alpha: 0.9,
|
||||||
alphaEnd: 0,
|
alphaEnd: 0,
|
||||||
rotationSpeedMin: -180,
|
rotationSpeedMin: -180,
|
||||||
rotationSpeedMax: 180,
|
rotationSpeedMax: 180,
|
||||||
|
speedOverLifeEnabled: false,
|
||||||
|
speedOverLifeMode: 'fixed',
|
||||||
|
speedOverLifeMin: 1,
|
||||||
|
speedOverLifeMax: 1,
|
||||||
|
speedOverLifeCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
|
||||||
|
scaleOverLifeEnabled: false,
|
||||||
|
scaleOverLifeMode: 'curve',
|
||||||
|
scaleOverLifeAxisMode: 'uniform',
|
||||||
|
scaleOverLifeMin: 0,
|
||||||
|
scaleOverLifeMax: 1,
|
||||||
|
scaleYOverLifeMin: 0,
|
||||||
|
scaleYOverLifeMax: 1,
|
||||||
|
scaleOverLifeCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
|
||||||
|
scaleYOverLifeCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
|
||||||
|
rotationOverLifeEnabled: false,
|
||||||
|
rotationOverLifeMode: 'random',
|
||||||
|
rotationOverLifeCurve: [{ x: 0, y: 0 }, { x: 1, y: 1 }],
|
||||||
gravity: 0,
|
gravity: 0,
|
||||||
damping: 0,
|
damping: 0,
|
||||||
seed: 0,
|
seed: 0,
|
||||||
@@ -182,6 +251,19 @@ export function defaultConfig(): EmitterConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 为热更新或旧版配置补齐后来新增的字段,避免 undefined 进入粒子计算。 */
|
||||||
|
export function ensureEmitterConfig(config: EmitterConfig): EmitterConfig {
|
||||||
|
const defaults = defaultConfig() as unknown as Record<string, unknown>
|
||||||
|
const target = config as unknown as Record<string, unknown>
|
||||||
|
for (const [key, value] of Object.entries(defaults)) {
|
||||||
|
if (target[key] !== undefined && target[key] !== null) continue
|
||||||
|
target[key] = Array.isArray(value)
|
||||||
|
? value.map((item) => typeof item === 'object' && item !== null ? { ...item } : item)
|
||||||
|
: value
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
interface Particle {
|
interface Particle {
|
||||||
sprite: Sprite
|
sprite: Sprite
|
||||||
active: boolean
|
active: boolean
|
||||||
@@ -190,9 +272,12 @@ interface Particle {
|
|||||||
maxLife: number
|
maxLife: number
|
||||||
x: number; y: number
|
x: number; y: number
|
||||||
vx: number; vy: number
|
vx: number; vy: number
|
||||||
scaleStart: number; scaleEnd: number
|
scaleStartX: number; scaleStartY: number
|
||||||
alphaStart: number; alphaEnd: number
|
alphaStart: number; alphaEnd: number
|
||||||
rotation: number; rotationSpeed: number
|
rotation: number; rotationSpeed: number
|
||||||
|
speedScale: number
|
||||||
|
speedScaleTarget: number
|
||||||
|
scaleOverLifeX: number; scaleOverLifeY: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const BLEND_MAP: Record<Blend, number> = { normal: 0, add: 1, multiply: 2, screen: 3 }
|
const BLEND_MAP: Record<Blend, number> = { normal: 0, add: 1, multiply: 2, screen: 3 }
|
||||||
@@ -215,7 +300,7 @@ export class ParticleEmitter extends Container {
|
|||||||
|
|
||||||
constructor(config: EmitterConfig, maxParticles = MAX_PARTICLES) {
|
constructor(config: EmitterConfig, maxParticles = MAX_PARTICLES) {
|
||||||
super()
|
super()
|
||||||
this.cfg = config
|
this.cfg = ensureEmitterConfig(config)
|
||||||
this._ensurePool(maxParticles)
|
this._ensurePool(maxParticles)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,7 +326,8 @@ export class ParticleEmitter extends Container {
|
|||||||
this.pool.push({
|
this.pool.push({
|
||||||
sprite, active: false, boneName: 'p_' + this.pool.length,
|
sprite, active: false, boneName: 'p_' + this.pool.length,
|
||||||
life: 0, maxLife: 1, x: 0, y: 0, vx: 0, vy: 0,
|
life: 0, maxLife: 1, x: 0, y: 0, vx: 0, vy: 0,
|
||||||
scaleStart: 1, scaleEnd: 1, alphaStart: 1, alphaEnd: 1, rotation: 0, rotationSpeed: 0,
|
scaleStartX: 1, scaleStartY: 1, alphaStart: 1, alphaEnd: 1, rotation: 0, rotationSpeed: 0,
|
||||||
|
speedScale: 1, speedScaleTarget: 1, scaleOverLifeX: 1, scaleOverLifeY: 1,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -250,7 +336,14 @@ export class ParticleEmitter extends Container {
|
|||||||
|
|
||||||
/** 每帧更新;返回活动粒子状态快照(与骨骼一一对应) */
|
/** 每帧更新;返回活动粒子状态快照(与骨骼一一对应) */
|
||||||
update(dt: number): ParticleState[] {
|
update(dt: number): ParticleState[] {
|
||||||
|
// HMR 会保留已创建的系统对象;旧对象缺少新增字段时在这里即时补齐。
|
||||||
const cfg = this.cfg
|
const cfg = this.cfg
|
||||||
|
if (
|
||||||
|
cfg.lifeMode == null || cfg.speedMode == null || cfg.scaleMode == null ||
|
||||||
|
cfg.initialRotationMode == null || cfg.speedOverLifeEnabled == null ||
|
||||||
|
cfg.scaleOverLifeEnabled == null || cfg.rotationOverLifeEnabled == null ||
|
||||||
|
!Array.isArray(cfg.scaleOverLifeCurve)
|
||||||
|
) ensureEmitterConfig(cfg)
|
||||||
this.reseed() // 种子变化即重建随机源
|
this.reseed() // 种子变化即重建随机源
|
||||||
// 累计发射时间
|
// 累计发射时间
|
||||||
this._elapsed += dt
|
this._elapsed += dt
|
||||||
@@ -298,11 +391,29 @@ export class ParticleEmitter extends Container {
|
|||||||
}
|
}
|
||||||
p.x += p.vx * dt
|
p.x += p.vx * dt
|
||||||
p.y += p.vy * dt
|
p.y += p.vy * dt
|
||||||
p.rotation += p.rotationSpeed * dt
|
|
||||||
const t = 1 - p.life / p.maxLife
|
const t = 1 - p.life / p.maxLife
|
||||||
const s = p.sprite
|
const s = p.sprite
|
||||||
|
|
||||||
|
if (cfg.speedOverLifeEnabled) {
|
||||||
|
const target = finite(overLifeValue(cfg.speedOverLifeMode, cfg.speedOverLifeMin, cfg.speedOverLifeMax, cfg.speedOverLifeCurve, t, p.speedScaleTarget), 1)
|
||||||
|
const ratio = p.speedScale === 0 ? target : target / p.speedScale
|
||||||
|
p.vx *= ratio; p.vy *= ratio; p.speedScale = target
|
||||||
|
}
|
||||||
|
|
||||||
|
let rotationSpeed = p.rotationSpeed
|
||||||
|
if (cfg.rotationOverLifeEnabled && cfg.rotationOverLifeMode === 'curve') {
|
||||||
|
rotationSpeed = lerp(cfg.rotationSpeedMin, cfg.rotationSpeedMax, curveAt(cfg.rotationOverLifeCurve, t))
|
||||||
|
}
|
||||||
|
p.rotation += rotationSpeed * dt
|
||||||
s.x = p.x; s.y = p.y; s.rotation = -p.rotation * Math.PI / 180
|
s.x = p.x; s.y = p.y; s.rotation = -p.rotation * Math.PI / 180
|
||||||
s.scale.set(p.scaleStart + (p.scaleEnd - p.scaleStart) * t)
|
let scaleMulX = 1, scaleMulY = 1
|
||||||
|
if (cfg.scaleOverLifeEnabled) {
|
||||||
|
scaleMulX = overLifeValue(cfg.scaleOverLifeMode, cfg.scaleOverLifeMin, cfg.scaleOverLifeMax, cfg.scaleOverLifeCurve, t, p.scaleOverLifeX)
|
||||||
|
scaleMulY = cfg.scaleOverLifeAxisMode === 'separate'
|
||||||
|
? overLifeValue(cfg.scaleOverLifeMode, cfg.scaleYOverLifeMin, cfg.scaleYOverLifeMax, cfg.scaleYOverLifeCurve, t, p.scaleOverLifeY)
|
||||||
|
: scaleMulX
|
||||||
|
}
|
||||||
|
s.scale.set(finite(p.scaleStartX * scaleMulX, 1), finite(p.scaleStartY * scaleMulY, 1))
|
||||||
s.alpha = cfg.alphaMode === 'curve' ? curveAt(cfg.alphaCurve, t) * cfg.alphaScale : (p.alphaStart + (p.alphaEnd - p.alphaStart) * t) * cfg.alphaScale
|
s.alpha = cfg.alphaMode === 'curve' ? curveAt(cfg.alphaCurve, t) * cfg.alphaScale : (p.alphaStart + (p.alphaEnd - p.alphaStart) * t) * cfg.alphaScale
|
||||||
const rgb = cfg.colorOverLife ? blendRgb(cfg.colorStart, cfg.colorEnd, t) : hexToNumber(cfg.colorStart)
|
const rgb = cfg.colorOverLife ? blendRgb(cfg.colorStart, cfg.colorEnd, t) : hexToNumber(cfg.colorStart)
|
||||||
s.tint = rgb
|
s.tint = rgb
|
||||||
@@ -340,8 +451,8 @@ export class ParticleEmitter extends Container {
|
|||||||
let idx = this.pool.findIndex((p) => !p.active)
|
let idx = this.pool.findIndex((p) => !p.active)
|
||||||
if (idx === -1) return // 池满
|
if (idx === -1) return // 池满
|
||||||
const { x: ox, y: oy } = this.spawnOrigin()
|
const { x: ox, y: oy } = this.spawnOrigin()
|
||||||
const life = lerp(cfg.lifeMin, cfg.lifeMax, this.rng())
|
const life = initialValue(cfg.lifeMode, cfg.lifeMin, cfg.lifeMax, cfg.lifeCurve, this.rng())
|
||||||
const speed = lerp(cfg.speedMin, cfg.speedMax, this.rng())
|
const speed = initialValue(cfg.speedMode, cfg.speedMin, cfg.speedMax, cfg.speedCurve, this.rng())
|
||||||
// 锥体:发射方向用锥形开口角度(coneAngle)散布,且 y 朝数学上正(与扇区绘制一致);其余形状沿用 spread + 屏幕坐标
|
// 锥体:发射方向用锥形开口角度(coneAngle)散布,且 y 朝数学上正(与扇区绘制一致);其余形状沿用 spread + 屏幕坐标
|
||||||
let dirAng: number
|
let dirAng: number
|
||||||
if (cfg.shape === 'cone') {
|
if (cfg.shape === 'cone') {
|
||||||
@@ -354,10 +465,27 @@ export class ParticleEmitter extends Container {
|
|||||||
p.x = ox; p.y = oy
|
p.x = ox; p.y = oy
|
||||||
p.vx = Math.cos(dirAng) * speed
|
p.vx = Math.cos(dirAng) * speed
|
||||||
p.vy = (cfg.shape === 'cone' ? -Math.sin(dirAng) : Math.sin(dirAng)) * speed
|
p.vy = (cfg.shape === 'cone' ? -Math.sin(dirAng) : Math.sin(dirAng)) * speed
|
||||||
p.scaleStart = lerp(cfg.scaleMin, cfg.scaleMax, this.rng())
|
p.scaleStartX = initialValue(cfg.scaleMode, cfg.scaleMin, cfg.scaleMax, cfg.scaleCurve, this.rng())
|
||||||
p.scaleEnd = p.scaleStart * cfg.scaleEndRatio
|
p.scaleStartY = cfg.scaleAxisMode === 'separate'
|
||||||
|
? initialValue(cfg.scaleMode, cfg.scaleYMin, cfg.scaleYMax, cfg.scaleYCurve, this.rng())
|
||||||
|
: p.scaleStartX
|
||||||
p.alphaStart = cfg.alpha; p.alphaEnd = cfg.alphaEnd
|
p.alphaStart = cfg.alpha; p.alphaEnd = cfg.alphaEnd
|
||||||
p.rotation = this.rng() * 360; p.rotationSpeed = lerp(cfg.rotationSpeedMin, cfg.rotationSpeedMax, this.rng())
|
p.rotation = cfg.initialRotationMode === 'direction'
|
||||||
|
? -Math.atan2(p.vy, p.vx) * 180 / Math.PI + cfg.initialRotationMin
|
||||||
|
: initialValue(cfg.initialRotationMode, cfg.initialRotationMin, cfg.initialRotationMax, cfg.initialRotationCurve, this.rng())
|
||||||
|
p.rotationSpeed = cfg.rotationOverLifeEnabled
|
||||||
|
? initialValue(cfg.rotationOverLifeMode, cfg.rotationSpeedMin, cfg.rotationSpeedMax, cfg.rotationOverLifeCurve, this.rng())
|
||||||
|
: 0
|
||||||
|
p.speedScale = 1
|
||||||
|
p.speedScaleTarget = cfg.speedOverLifeMode === 'random'
|
||||||
|
? lerp(cfg.speedOverLifeMin, cfg.speedOverLifeMax, this.rng())
|
||||||
|
: cfg.speedOverLifeMin
|
||||||
|
p.scaleOverLifeX = cfg.scaleOverLifeMode === 'random'
|
||||||
|
? lerp(cfg.scaleOverLifeMin, cfg.scaleOverLifeMax, this.rng())
|
||||||
|
: cfg.scaleOverLifeMin
|
||||||
|
p.scaleOverLifeY = cfg.scaleOverLifeMode === 'random'
|
||||||
|
? lerp(cfg.scaleYOverLifeMin, cfg.scaleYOverLifeMax, this.rng())
|
||||||
|
: cfg.scaleYOverLifeMin
|
||||||
p.sprite.tint = hexToNumber(cfg.colorStart)
|
p.sprite.tint = hexToNumber(cfg.colorStart)
|
||||||
p.sprite.visible = true
|
p.sprite.visible = true
|
||||||
}
|
}
|
||||||
@@ -421,9 +549,19 @@ export class ParticleEmitter extends Container {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function lerp(a: number, b: number, t: number) { return a + (b - a) * t }
|
function lerp(a: number, b: number, t: number) { return a + (b - a) * t }
|
||||||
|
function finite(value: number, fallback: number) { return Number.isFinite(value) ? value : fallback }
|
||||||
|
function initialValue(mode: AttributeMode, min: number, max: number, curve: CurvePoint[], random: number) {
|
||||||
|
if (mode === 'fixed') return min
|
||||||
|
if (mode === 'curve') return lerp(min, max, curveAt(curve, random))
|
||||||
|
return lerp(min, max, random)
|
||||||
|
}
|
||||||
|
function overLifeValue(mode: AttributeMode, min: number, max: number, curve: CurvePoint[], age: number, sampled: number) {
|
||||||
|
if (mode === 'curve') return lerp(min, max, curveAt(curve, age))
|
||||||
|
return mode === 'random' ? sampled : min
|
||||||
|
}
|
||||||
/** 在透明度曲线关键帧上按 x(生命周期时刻 0~1)求 y(透明度 0~1)。保证有序、端点外取端点 */
|
/** 在透明度曲线关键帧上按 x(生命周期时刻 0~1)求 y(透明度 0~1)。保证有序、端点外取端点 */
|
||||||
function curveAt(pts: { x: number; y: number }[], x: number): number {
|
function curveAt(pts: CurvePoint[] | undefined, x: number): number {
|
||||||
if (!pts || pts.length === 0) return 1
|
if (!Array.isArray(pts) || pts.length === 0) return 1
|
||||||
const p = [...pts].sort((a, b) => a.x - b.x)
|
const p = [...pts].sort((a, b) => a.x - b.x)
|
||||||
if (x <= p[0].x) return p[0].y
|
if (x <= p[0].x) return p[0].y
|
||||||
if (x >= p[p.length - 1].x) return p[p.length - 1].y
|
if (x >= p[p.length - 1].x) return p[p.length - 1].y
|
||||||
|
|||||||
+6
-1
@@ -1,9 +1,14 @@
|
|||||||
html, body {
|
html, body {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
overflow: clip;
|
||||||
|
overscroll-behavior: none;
|
||||||
background: #0b0b12;
|
background: #0b0b12;
|
||||||
color: #e8e8f0;
|
color: #e8e8f0;
|
||||||
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||||
}
|
}
|
||||||
|
body { position: fixed; inset: 0; }
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
#app { width: 100%; height: 100vh; }
|
#app { position: fixed; inset: 0; width: auto; height: auto; overflow: clip; }
|
||||||
|
|||||||
+39
-19
@@ -1,29 +1,39 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="ce">
|
<div class="ce">
|
||||||
<svg ref="svg" class="ce-svg" @dblclick="onDbl">
|
<div class="ce-editor">
|
||||||
<!-- 网格线(0.25/0.5/0.75) -->
|
<svg ref="svg" class="ce-svg" @dblclick="onDbl">
|
||||||
<g class="ce-grid">
|
<!-- 网格线(0.25/0.5/0.75) -->
|
||||||
<line v-for="g in [0.25,0.5,0.75]" :key="'v'+g" :x1="px(g)" y1="0" :x2="px(g)" :y2="py(0)" />
|
<g class="ce-grid">
|
||||||
<line v-for="g in [0.25,0.5,0.75]" :key="'h'+g" x1="0" :y1="py(g)" :x2="px(1)" :y2="py(g)" />
|
<line v-for="g in [0.25,0.5,0.75]" :key="'v'+g" :x1="px(g)" y1="0" :x2="px(g)" :y2="py(0)" />
|
||||||
</g>
|
<line v-for="g in [0.25,0.5,0.75]" :key="'h'+g" x1="0" :y1="py(g)" :x2="px(1)" :y2="py(g)" />
|
||||||
<rect :x="px(0)" :y="py(1)" :width="px(1)-px(0)" :height="py(0)-py(1)" class="ce-frame" fill="none" />
|
</g>
|
||||||
<!-- 折线 -->
|
<rect :x="px(0)" :y="py(1)" :width="px(1)-px(0)" :height="py(0)-py(1)" class="ce-frame" fill="none" />
|
||||||
<polyline :points="polyPoints" class="ce-line" fill="none" />
|
<!-- 折线 -->
|
||||||
<!-- 节点:可见圈 r=5 + 命中圈 r=9 -->
|
<polyline :points="polyPoints" class="ce-line" fill="none" />
|
||||||
<g v-for="(pt,i) in pts" :key="i">
|
<!-- 节点:可见圈 r=5 + 命中圈 r=9 -->
|
||||||
<circle :cx="px(pt.x)" :cy="py(pt.y)" r="5" class="ce-node" />
|
<g v-for="(pt,i) in pts" :key="i">
|
||||||
<circle :cx="px(pt.x)" :cy="py(pt.y)" r="9" class="ce-hit"
|
<circle :cx="px(pt.x)" :cy="py(pt.y)" r="5" class="ce-node" />
|
||||||
@pointerdown="onNodeDown($event,i)" @dblclick="onNodeDbl(i,$event)" />
|
<circle :cx="px(pt.x)" :cy="py(pt.y)" r="9" class="ce-hit"
|
||||||
</g>
|
@pointerdown="onNodeDown($event,i)" @dblclick="onNodeDbl(i,$event)" />
|
||||||
</svg>
|
</g>
|
||||||
<div class="ce-nav"><span class="ce-tip">双击空白新建·双击节点删除·拖动节点调整</span></div>
|
</svg>
|
||||||
|
<div class="ce-nav"><span class="ce-tip">双击空白新建·双击节点删除·拖动节点调整</span></div>
|
||||||
|
</div>
|
||||||
|
<button class="ce-reset" type="button" title="恢复默认曲线" @click="resetCurve">重置</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
const props = defineProps<{ modelValue: { x: number; y: number }[] }>()
|
type CurvePoint = { x: number; y: number }
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
modelValue: CurvePoint[]
|
||||||
|
defaultValue?: CurvePoint[]
|
||||||
|
}>(), {
|
||||||
|
defaultValue: () => [{ x: 0, y: 0 }, { x: 1, y: 1 }],
|
||||||
|
})
|
||||||
const emit = defineEmits<{ (e: 'update:modelValue', v: { x: number; y: number }[]): void }>()
|
const emit = defineEmits<{ (e: 'update:modelValue', v: { x: number; y: number }[]): void }>()
|
||||||
|
|
||||||
const svg = ref<SVGSVGElement | null>(null)
|
const svg = ref<SVGSVGElement | null>(null)
|
||||||
@@ -49,6 +59,10 @@ function normalized(): { x: number; y: number }[] {
|
|||||||
}
|
}
|
||||||
function set(v: { x: number; y: number }[]) { emit('update:modelValue', [...v].sort((a, b) => a.x - b.x)) }
|
function set(v: { x: number; y: number }[]) { emit('update:modelValue', [...v].sort((a, b) => a.x - b.x)) }
|
||||||
|
|
||||||
|
function resetCurve() {
|
||||||
|
set(props.defaultValue.map((point) => ({ ...point })))
|
||||||
|
}
|
||||||
|
|
||||||
const pts = computed(() => normalized())
|
const pts = computed(() => normalized())
|
||||||
const polyPoints = computed(() => pts.value.map(p => `${px(p.x)},${py(p.y)}`).join(' '))
|
const polyPoints = computed(() => pts.value.map(p => `${px(p.x)},${py(p.y)}`).join(' '))
|
||||||
|
|
||||||
@@ -121,7 +135,13 @@ onUnmounted(() => { ro?.disconnect() })
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.ce { width: 100%; }
|
.ce { display: flex; align-items: flex-start; gap: 6px; width: 100%; }
|
||||||
|
.ce-editor { flex: 1; min-width: 0; }
|
||||||
|
.ce-reset {
|
||||||
|
flex: 0 0 auto; padding: 5px 7px; border: 1px solid #3b4b65; border-radius: 5px;
|
||||||
|
background: #242f42; color: #c4cee2; font-size: 10px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.ce-reset:hover { border-color: #7774ff; background: #4f4dc2; color: #fff; }
|
||||||
.ce-svg { width: 100%; height: 110px; display: block; border: 1px solid #2a2a3c; border-radius: 6px; background: #10101a; cursor: crosshair; touch-action: none; }
|
.ce-svg { width: 100%; height: 110px; display: block; border: 1px solid #2a2a3c; border-radius: 6px; background: #10101a; cursor: crosshair; touch-action: none; }
|
||||||
.ce-grid line { stroke: #1e1e30; stroke-width: 1; }
|
.ce-grid line { stroke: #1e1e30; stroke-width: 1; }
|
||||||
.ce-frame { stroke: #2e2e44; stroke-width: 1; }
|
.ce-frame { stroke: #2e2e44; stroke-width: 1; }
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<template>
|
||||||
|
<div class="pac">
|
||||||
|
<div class="pac-head">
|
||||||
|
<span class="pac-label">{{ label }}</span>
|
||||||
|
<div v-if="showModes" class="pac-modes">
|
||||||
|
<button
|
||||||
|
v-for="item in modeItems"
|
||||||
|
:key="item.value"
|
||||||
|
class="pac-mode"
|
||||||
|
:class="{ on: mode === item.value }"
|
||||||
|
@click="$emit('update:mode', item.value)"
|
||||||
|
>{{ item.label }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="mode === 'fixed' || mode === 'direction'">
|
||||||
|
<NumSlider
|
||||||
|
:label="mode === 'direction' ? '角度偏移' : '数值'"
|
||||||
|
:min="min"
|
||||||
|
:max="max"
|
||||||
|
:step="step"
|
||||||
|
:model-value="valueMin"
|
||||||
|
@update:model-value="$emit('update:valueMin', $event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="mode === 'random'">
|
||||||
|
<NumSlider label="下限" :min="min" :max="max" :step="step" :model-value="valueMin" @update:model-value="$emit('update:valueMin', $event)" />
|
||||||
|
<NumSlider label="上限" :min="min" :max="max" :step="step" :model-value="valueMax" @update:model-value="$emit('update:valueMax', $event)" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="pac-range">
|
||||||
|
<NumSlider label="范围下限" :min="min" :max="max" :step="step" :model-value="valueMin" @update:model-value="$emit('update:valueMin', $event)" />
|
||||||
|
<NumSlider label="范围上限" :min="min" :max="max" :step="step" :model-value="valueMax" @update:model-value="$emit('update:valueMax', $event)" />
|
||||||
|
</div>
|
||||||
|
<div class="pac-curve">
|
||||||
|
<CurveEditor :model-value="curve" :default-value="defaultCurve" @update:model-value="$emit('update:curve', $event)" />
|
||||||
|
<div class="pac-axis"><span>0</span><span>{{ curveLabel }}</span><span>1</span></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import NumSlider from './NumSlider.vue'
|
||||||
|
import CurveEditor from './CurveEditor.vue'
|
||||||
|
|
||||||
|
export type AttributeMode = 'fixed' | 'random' | 'curve' | 'direction'
|
||||||
|
type CurvePoint = { x: number; y: number }
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
label: string
|
||||||
|
mode: AttributeMode
|
||||||
|
valueMin: number
|
||||||
|
valueMax: number
|
||||||
|
curve: CurvePoint[]
|
||||||
|
defaultCurve?: CurvePoint[]
|
||||||
|
min: number
|
||||||
|
max: number
|
||||||
|
step?: number
|
||||||
|
allowDirection?: boolean
|
||||||
|
allowCurve?: boolean
|
||||||
|
showModes?: boolean
|
||||||
|
curveLabel?: string
|
||||||
|
}>(), {
|
||||||
|
step: 1,
|
||||||
|
allowDirection: false,
|
||||||
|
allowCurve: true,
|
||||||
|
showModes: true,
|
||||||
|
curveLabel: '曲线输入 →',
|
||||||
|
defaultCurve: () => [{ x: 0, y: 0 }, { x: 1, y: 1 }],
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: 'update:mode', value: AttributeMode): void
|
||||||
|
(e: 'update:valueMin', value: number): void
|
||||||
|
(e: 'update:valueMax', value: number): void
|
||||||
|
(e: 'update:curve', value: CurvePoint[]): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const modeItems = computed(() => {
|
||||||
|
const items: { label: string; value: AttributeMode }[] = [
|
||||||
|
{ label: '固定', value: 'fixed' },
|
||||||
|
{ label: '随机', value: 'random' },
|
||||||
|
]
|
||||||
|
if (props.allowCurve) items.push({ label: '曲线', value: 'curve' })
|
||||||
|
if (props.allowDirection) items.push({ label: '发射方向', value: 'direction' })
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pac { padding: 10px; margin: 8px 0; border: 1px solid #2c3850; border-radius: 7px; background: #171d2a; }
|
||||||
|
.pac-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 7px; }
|
||||||
|
.pac-label { color: #aeb8cf; font-size: 12px; font-weight: 600; white-space: nowrap; }
|
||||||
|
.pac-modes { display: flex; gap: 4px; flex-wrap: wrap; justify-content: flex-end; }
|
||||||
|
.pac-mode { padding: 4px 7px; border: 1px solid #344158; border-radius: 5px; background: #273246; color: #aeb8cf; font-size: 11px; cursor: pointer; }
|
||||||
|
.pac-mode.on { border-color: #7774ff; background: #605df0; color: #fff; }
|
||||||
|
.pac-curve { margin-top: 8px; }
|
||||||
|
.pac-axis { display: flex; justify-content: space-between; margin-top: 3px; color: #5f6b82; font-size: 10px; }
|
||||||
|
.pac :deep(.ns-label) { width: 58px; }
|
||||||
|
.pac :deep(.ce-svg) { height: 96px; background: #0f1625; border-color: #30405a; }
|
||||||
|
.pac :deep(.ce-tip) { color: #657188; }
|
||||||
|
</style>
|
||||||
+224
-12
@@ -97,15 +97,153 @@
|
|||||||
<div class="group-head" @click="toggle('attr')">
|
<div class="group-head" @click="toggle('attr')">
|
||||||
<span class="caret">{{ collapsed.attr ? '▸' : '▾' }}</span><span>粒子属性</span>
|
<span class="caret">{{ collapsed.attr ? '▸' : '▾' }}</span><span>粒子属性</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="group-body" v-show="!collapsed.attr">
|
<div class="group-body attr-body" v-show="!collapsed.attr">
|
||||||
<NumSlider label="速度下限" :min="0" :max="400" :step="1" v-model="sys.config.speedMin" />
|
<div class="attr-section-title">粒子初始参数</div>
|
||||||
<NumSlider label="速度上限" :min="0" :max="600" :step="1" v-model="sys.config.speedMax" />
|
|
||||||
<NumSlider label="生命下限" :min="0.1" :max="5" :step="0.1" v-model="sys.config.lifeMin" />
|
<ParticleAttributeControl
|
||||||
<NumSlider label="生命上限" :min="0.1" :max="6" :step="0.1" v-model="sys.config.lifeMax" />
|
label="初始生命周期"
|
||||||
<NumSlider label="缩放下限" :min="0.05" :max="3" :step="0.05" v-model="sys.config.scaleMin" />
|
:allow-curve="false"
|
||||||
<NumSlider label="缩放上限" :min="0.05" :max="5" :step="0.05" v-model="sys.config.scaleMax" />
|
v-model:mode="sys.config.lifeMode"
|
||||||
<NumSlider label="旋转速度" :min="-360" :max="360" :step="10" v-model="sys.config.rotationSpeedMin" />
|
v-model:value-min="sys.config.lifeMin"
|
||||||
<NumSlider label="随机种子" :min="0" :max="9999" :step="1" v-model="sys.config.seed" />
|
v-model:value-max="sys.config.lifeMax"
|
||||||
|
v-model:curve="sys.config.lifeCurve"
|
||||||
|
:min="0.1" :max="10" :step="0.1"
|
||||||
|
curve-label="随机采样 →"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ParticleAttributeControl
|
||||||
|
label="初始速度"
|
||||||
|
:allow-curve="false"
|
||||||
|
v-model:mode="sys.config.speedMode"
|
||||||
|
v-model:value-min="sys.config.speedMin"
|
||||||
|
v-model:value-max="sys.config.speedMax"
|
||||||
|
v-model:curve="sys.config.speedCurve"
|
||||||
|
:min="0" :max="1000" :step="1"
|
||||||
|
curve-label="随机采样 →"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="attr-axis-head">
|
||||||
|
<span>初始缩放</span>
|
||||||
|
<div class="axis-modes">
|
||||||
|
<button class="axis-mode" :class="{ on: sys.config.scaleAxisMode === 'uniform' }" @click="sys.config.scaleAxisMode = 'uniform'">统一</button>
|
||||||
|
<button class="axis-mode" :class="{ on: sys.config.scaleAxisMode === 'separate' }" @click="sys.config.scaleAxisMode = 'separate'">XY分离</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ParticleAttributeControl
|
||||||
|
:label="sys.config.scaleAxisMode === 'uniform' ? '初始缩放' : '缩放 X'"
|
||||||
|
:allow-curve="false"
|
||||||
|
v-model:mode="sys.config.scaleMode"
|
||||||
|
v-model:value-min="sys.config.scaleMin"
|
||||||
|
v-model:value-max="sys.config.scaleMax"
|
||||||
|
v-model:curve="sys.config.scaleCurve"
|
||||||
|
:min="0" :max="5" :step="0.05"
|
||||||
|
curve-label="随机采样 →"
|
||||||
|
/>
|
||||||
|
<ParticleAttributeControl
|
||||||
|
v-if="sys.config.scaleAxisMode === 'separate'"
|
||||||
|
label="缩放 Y"
|
||||||
|
:show-modes="false"
|
||||||
|
:allow-curve="false"
|
||||||
|
v-model:mode="sys.config.scaleMode"
|
||||||
|
v-model:value-min="sys.config.scaleYMin"
|
||||||
|
v-model:value-max="sys.config.scaleYMax"
|
||||||
|
v-model:curve="sys.config.scaleYCurve"
|
||||||
|
:min="0" :max="5" :step="0.05"
|
||||||
|
curve-label="随机采样 →"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ParticleAttributeControl
|
||||||
|
label="初始旋转"
|
||||||
|
allow-direction
|
||||||
|
:allow-curve="false"
|
||||||
|
v-model:mode="sys.config.initialRotationMode"
|
||||||
|
v-model:value-min="sys.config.initialRotationMin"
|
||||||
|
v-model:value-max="sys.config.initialRotationMax"
|
||||||
|
v-model:curve="sys.config.initialRotationCurve"
|
||||||
|
:min="-360" :max="360" :step="1"
|
||||||
|
curve-label="随机采样 →"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="attr-section-title life-title">粒子生命周期变化参数</div>
|
||||||
|
|
||||||
|
<div class="life-item">
|
||||||
|
<label class="switch-row">
|
||||||
|
<input type="checkbox" v-model="sys.config.speedOverLifeEnabled" />
|
||||||
|
<span class="switch-ui"></span><span>生命周期内速度</span>
|
||||||
|
</label>
|
||||||
|
<ParticleAttributeControl
|
||||||
|
v-if="sys.config.speedOverLifeEnabled"
|
||||||
|
label="速度倍率"
|
||||||
|
:mode="sys.config.speedOverLifeMode"
|
||||||
|
@update:mode="setSpeedOverLifeMode"
|
||||||
|
v-model:value-min="sys.config.speedOverLifeMin"
|
||||||
|
v-model:value-max="sys.config.speedOverLifeMax"
|
||||||
|
v-model:curve="sys.config.speedOverLifeCurve"
|
||||||
|
:default-curve="FLAT_CURVE"
|
||||||
|
:min="0" :max="3" :step="0.05"
|
||||||
|
curve-label="生命周期 →"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="life-item">
|
||||||
|
<label class="switch-row">
|
||||||
|
<input type="checkbox" v-model="sys.config.scaleOverLifeEnabled" @change="prepareScaleOverLifeCurve" />
|
||||||
|
<span class="switch-ui"></span><span>生命周期内缩放</span>
|
||||||
|
</label>
|
||||||
|
<template v-if="sys.config.scaleOverLifeEnabled">
|
||||||
|
<div class="axis-modes life-axis">
|
||||||
|
<button class="axis-mode" :class="{ on: sys.config.scaleOverLifeAxisMode === 'uniform' }" @click="sys.config.scaleOverLifeAxisMode = 'uniform'">统一</button>
|
||||||
|
<button class="axis-mode" :class="{ on: sys.config.scaleOverLifeAxisMode === 'separate' }" @click="sys.config.scaleOverLifeAxisMode = 'separate'">XY分离</button>
|
||||||
|
</div>
|
||||||
|
<ParticleAttributeControl
|
||||||
|
:label="sys.config.scaleOverLifeAxisMode === 'uniform' ? '缩放倍率' : '倍率 X'"
|
||||||
|
:mode="sys.config.scaleOverLifeMode"
|
||||||
|
@update:mode="setScaleOverLifeMode"
|
||||||
|
v-model:value-min="sys.config.scaleOverLifeMin"
|
||||||
|
v-model:value-max="sys.config.scaleOverLifeMax"
|
||||||
|
v-model:curve="sys.config.scaleOverLifeCurve"
|
||||||
|
:default-curve="FLAT_CURVE"
|
||||||
|
:min="0" :max="3" :step="0.05"
|
||||||
|
curve-label="生命周期 →"
|
||||||
|
/>
|
||||||
|
<ParticleAttributeControl
|
||||||
|
v-if="sys.config.scaleOverLifeAxisMode === 'separate'"
|
||||||
|
label="倍率 Y"
|
||||||
|
:show-modes="false"
|
||||||
|
v-model:mode="sys.config.scaleOverLifeMode"
|
||||||
|
v-model:value-min="sys.config.scaleYOverLifeMin"
|
||||||
|
v-model:value-max="sys.config.scaleYOverLifeMax"
|
||||||
|
v-model:curve="sys.config.scaleYOverLifeCurve"
|
||||||
|
:default-curve="FLAT_CURVE"
|
||||||
|
:min="0" :max="3" :step="0.05"
|
||||||
|
curve-label="生命周期 →"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="life-item">
|
||||||
|
<label class="switch-row">
|
||||||
|
<input type="checkbox" v-model="sys.config.rotationOverLifeEnabled" />
|
||||||
|
<span class="switch-ui"></span><span>生命周期内旋转</span>
|
||||||
|
</label>
|
||||||
|
<ParticleAttributeControl
|
||||||
|
v-if="sys.config.rotationOverLifeEnabled"
|
||||||
|
label="旋转速度"
|
||||||
|
v-model:mode="sys.config.rotationOverLifeMode"
|
||||||
|
v-model:value-min="sys.config.rotationSpeedMin"
|
||||||
|
v-model:value-max="sys.config.rotationSpeedMax"
|
||||||
|
v-model:curve="sys.config.rotationOverLifeCurve"
|
||||||
|
:default-curve="LINEAR_CURVE"
|
||||||
|
:min="-720" :max="720" :step="10"
|
||||||
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -131,7 +269,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<div class="curve-wrap">
|
<div class="curve-wrap">
|
||||||
<CurveEditor v-model="sys.config.alphaCurve" />
|
<CurveEditor v-model="sys.config.alphaCurve" :default-value="ALPHA_CURVE" />
|
||||||
<div class="curve-labels"><span>0(透明)</span><span>生命周期 →</span><span>1(不透明)</span></div>
|
<div class="curve-labels"><span>0(透明)</span><span>生命周期 →</span><span>1(不透明)</span></div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -226,10 +364,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, reactive, ref } from 'vue'
|
import { computed, reactive, ref, watchEffect } from 'vue'
|
||||||
import { useParticleStore } from '../store/particleStore'
|
import { useParticleStore } from '../store/particleStore'
|
||||||
|
import { ensureEmitterConfig } from '../core/particleEmitter'
|
||||||
import NumSlider from './NumSlider.vue'
|
import NumSlider from './NumSlider.vue'
|
||||||
import CurveEditor from './CurveEditor.vue'
|
import CurveEditor from './CurveEditor.vue'
|
||||||
|
import ParticleAttributeControl from './ParticleAttributeControl.vue'
|
||||||
|
|
||||||
const store = useParticleStore()
|
const store = useParticleStore()
|
||||||
const activeIdx = computed(() => {
|
const activeIdx = computed(() => {
|
||||||
@@ -238,6 +378,21 @@ const activeIdx = computed(() => {
|
|||||||
})
|
})
|
||||||
const sys = computed(() => store.systems[activeIdx.value] || null)
|
const sys = computed(() => store.systems[activeIdx.value] || null)
|
||||||
|
|
||||||
|
// 开发热更新会保留旧的 Pinia 对象;渲染属性控件前先补齐新增字段。
|
||||||
|
watchEffect(() => {
|
||||||
|
if (!sys.value) return
|
||||||
|
const config = ensureEmitterConfig(sys.value.config as any)
|
||||||
|
// 初始属性不再提供曲线模式;旧配置自动回退为随机。
|
||||||
|
if (config.lifeMode === 'curve') config.lifeMode = 'random'
|
||||||
|
if (config.speedMode === 'curve') config.speedMode = 'random'
|
||||||
|
if (config.scaleMode === 'curve') config.scaleMode = 'random'
|
||||||
|
if (config.initialRotationMode === 'curve') config.initialRotationMode = 'random'
|
||||||
|
})
|
||||||
|
|
||||||
|
const LINEAR_CURVE = [{ x: 0, y: 0 }, { x: 1, y: 1 }]
|
||||||
|
const FLAT_CURVE = [{ x: 0, y: 1 }, { x: 1, y: 1 }]
|
||||||
|
const ALPHA_CURVE = [{ x: 0, y: 0 }, { x: 0.2, y: 1 }, { x: 0.8, y: 1 }, { x: 1, y: 0 }]
|
||||||
|
|
||||||
const collapsed = reactive<Record<string, boolean>>({
|
const collapsed = reactive<Record<string, boolean>>({
|
||||||
scene: false, emitMode: false, shape: false, attr: false, look: false, mods: false, export: false,
|
scene: false, emitMode: false, shape: false, attr: false, look: false, mods: false, export: false,
|
||||||
})
|
})
|
||||||
@@ -245,6 +400,40 @@ function toggle(key: string) { collapsed[key] = !collapsed[key] }
|
|||||||
|
|
||||||
function select(i: number) { store.selectSystem(store.systems[i]?.id ?? 0) }
|
function select(i: number) { store.selectSystem(store.systems[i]?.id ?? 0) }
|
||||||
function addSys() { store.addSystem() }
|
function addSys() { store.addSystem() }
|
||||||
|
function randomizeSeed() {
|
||||||
|
if (sys.value) sys.value.config.seed = Math.floor(Math.random() * 999999) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSpeedOverLifeMode(mode: 'fixed' | 'random' | 'curve' | 'direction') {
|
||||||
|
const config = sys.value?.config
|
||||||
|
if (!config || mode === 'direction') return
|
||||||
|
config.speedOverLifeMode = mode
|
||||||
|
// 旧默认是 1~1,曲线映射后永远为 1;切换曲线时改为有效倍率范围。
|
||||||
|
if (mode === 'curve' && config.speedOverLifeMin === config.speedOverLifeMax) {
|
||||||
|
config.speedOverLifeMin = 0
|
||||||
|
config.speedOverLifeMax = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareScaleOverLifeCurve() {
|
||||||
|
const config = sys.value?.config
|
||||||
|
if (!config || config.scaleOverLifeMode !== 'curve') return
|
||||||
|
if (config.scaleOverLifeMin === config.scaleOverLifeMax) {
|
||||||
|
config.scaleOverLifeMin = 0
|
||||||
|
config.scaleOverLifeMax = 1
|
||||||
|
}
|
||||||
|
if (config.scaleYOverLifeMin === config.scaleYOverLifeMax) {
|
||||||
|
config.scaleYOverLifeMin = 0
|
||||||
|
config.scaleYOverLifeMax = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setScaleOverLifeMode(mode: 'fixed' | 'random' | 'curve' | 'direction') {
|
||||||
|
const config = sys.value?.config
|
||||||
|
if (!config || mode === 'direction') return
|
||||||
|
config.scaleOverLifeMode = mode
|
||||||
|
prepareScaleOverLifeCurve()
|
||||||
|
}
|
||||||
|
|
||||||
// 各发射器形状的默认参数:切换形状时应用(只影响形状相关字段,不牵连其他)
|
// 各发射器形状的默认参数:切换形状时应用(只影响形状相关字段,不牵连其他)
|
||||||
const SHAPE_DEFAULTS: Record<string, Partial<any>> = {
|
const SHAPE_DEFAULTS: Record<string, Partial<any>> = {
|
||||||
@@ -270,7 +459,7 @@ function clearTexture() { resetTexture() }
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.panel { width: 100%; height: 100%; overflow-y: auto; background: #14141f; border-left: 1px solid #2a2a3c; padding: 12px; box-sizing: border-box; }
|
.panel { width: 100%; height: 100%; overflow-y: auto; overscroll-behavior: contain; background: #14141f; border-left: 1px solid #2a2a3c; padding: 12px; box-sizing: border-box; }
|
||||||
/* 深色主题滚动条:细窄、圆角、暗色,避免默认白条突兀 */
|
/* 深色主题滚动条:细窄、圆角、暗色,避免默认白条突兀 */
|
||||||
.panel::-webkit-scrollbar { width: 6px; }
|
.panel::-webkit-scrollbar { width: 6px; }
|
||||||
.panel::-webkit-scrollbar-track { background: transparent; }
|
.panel::-webkit-scrollbar-track { background: transparent; }
|
||||||
@@ -328,4 +517,27 @@ function clearTexture() { resetTexture() }
|
|||||||
.thumb-note { color: #667; font-size: 11px; margin: 4px 0 2px; }
|
.thumb-note { color: #667; font-size: 11px; margin: 4px 0 2px; }
|
||||||
.hex { font-size: 11px; color: #889; margin-left: auto; font-variant-numeric: tabular-nums; }
|
.hex { font-size: 11px; color: #889; margin-left: auto; font-variant-numeric: tabular-nums; }
|
||||||
.inp.tiny { width: 46px; flex: 0 0 46px; text-align: center; }
|
.inp.tiny { width: 46px; flex: 0 0 46px; text-align: center; }
|
||||||
|
|
||||||
|
/* 粒子属性面板 */
|
||||||
|
.attr-body { padding: 10px; background: #151b27; }
|
||||||
|
.attr-section-title { margin: 2px 0 8px; color: #9ca8c0; font-size: 12px; font-weight: 700; }
|
||||||
|
.attr-section-title.life-title { margin-top: 14px; padding-top: 12px; border-top: 1px solid #2d3950; }
|
||||||
|
.attr-axis-head { display: flex; align-items: center; justify-content: space-between; margin: 10px 1px -2px; color: #aeb8cf; font-size: 12px; font-weight: 600; }
|
||||||
|
.axis-modes { display: flex; gap: 4px; }
|
||||||
|
.axis-mode { padding: 4px 8px; border: 1px solid #344158; border-radius: 5px; background: #273246; color: #aeb8cf; font-size: 11px; cursor: pointer; }
|
||||||
|
.axis-mode.on { border-color: #7774ff; background: #605df0; color: #fff; }
|
||||||
|
.life-item { padding: 9px 0; border-bottom: 1px solid #2a3549; }
|
||||||
|
.switch-row { display: flex; align-items: center; gap: 9px; color: #b8c1d5; font-size: 12px; cursor: pointer; }
|
||||||
|
.switch-row input { position: absolute; opacity: 0; pointer-events: none; }
|
||||||
|
.switch-ui { position: relative; width: 32px; height: 18px; border-radius: 10px; background: #354258; transition: background .16s ease; }
|
||||||
|
.switch-ui::after { content: ''; position: absolute; left: 2px; top: 2px; width: 14px; height: 14px; border-radius: 50%; background: #fff; transition: transform .16s ease; }
|
||||||
|
.switch-row input:checked + .switch-ui { background: #625ff1; }
|
||||||
|
.switch-row input:checked + .switch-ui::after { transform: translateX(14px); }
|
||||||
|
.life-axis { margin: 9px 0 2px 42px; }
|
||||||
|
.seed-row { display: flex; align-items: center; gap: 8px; margin-top: 12px; padding-top: 12px; border-top: 1px solid #2d3950; }
|
||||||
|
.seed-row label { width: 64px; color: #8f9bb5; font-size: 12px; }
|
||||||
|
.seed-input { min-width: 0; flex: 1; padding: 6px 8px; border: 1px solid #344158; border-radius: 6px; outline: none; background: #0f1625; color: #e2e7f2; font-size: 12px; }
|
||||||
|
.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; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user