资源与外观调整
This commit is contained in:
1 parent
2ad0b76d11
commit
f0f4104b0c
6 files changed
+684
-60
No files matched your search
+164
-8
@@ -12,10 +12,38 @@ 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 ColorGradientStop { position: number; color: string }
|
||||
|
||||
export interface ParticleImageResource {
|
||||
id: number
|
||||
texture: Texture<any> | null
|
||||
textureName: string
|
||||
textureFolder: string
|
||||
previewUrl: string
|
||||
/** 发射权重,多个资源按权重随机选择 */
|
||||
weight: number
|
||||
/** 锁定后,调整其他资源时保持当前占比 */
|
||||
locked: boolean
|
||||
scale: number
|
||||
anchorX: number
|
||||
anchorY: number
|
||||
/** 每张贴图独立的颜色、混合与透明度设置 */
|
||||
colorMode: 'fixed' | 'lifetime'
|
||||
colorStart: string
|
||||
colorEnd: string
|
||||
colorGradient: ColorGradientStop[]
|
||||
blend: Blend
|
||||
independentAlpha: 'off' | 'fixed' | 'curve'
|
||||
alpha: number
|
||||
alphaCurveMode: '跟随导出' | '线性' | '贝塞尔'
|
||||
alphaCurve: CurvePoint[]
|
||||
}
|
||||
|
||||
/** 粒子配置(引用保持,面板改字段即实时生效) */
|
||||
export interface EmitterConfig {
|
||||
texture: Texture<any>
|
||||
imageResources: ParticleImageResource[]
|
||||
imageWeightVersion: number
|
||||
name: string
|
||||
// 发射
|
||||
mode: EmitMode
|
||||
@@ -151,12 +179,35 @@ export interface ParticleState {
|
||||
scaleY: number
|
||||
alpha: number
|
||||
colorHex: number
|
||||
resourceId?: number
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export function defaultConfig(): EmitterConfig {
|
||||
return {
|
||||
texture: null as unknown as Texture,
|
||||
imageResources: [{
|
||||
id: 1,
|
||||
texture: null,
|
||||
textureName: 'star',
|
||||
textureFolder: '',
|
||||
previewUrl: '/particles/star.png',
|
||||
weight: 100,
|
||||
locked: false,
|
||||
scale: 1,
|
||||
anchorX: 0.5,
|
||||
anchorY: 0.5,
|
||||
colorMode: 'fixed',
|
||||
colorStart: '#ffffff',
|
||||
colorEnd: '#ff4d4d',
|
||||
colorGradient: [{ position: 0, color: '#ffffff' }, { position: 1, color: '#ff4d4d' }],
|
||||
blend: 'normal',
|
||||
independentAlpha: 'off',
|
||||
alpha: 1,
|
||||
alphaCurveMode: '跟随导出',
|
||||
alphaCurve: [{ x: 0, y: 0 }, { x: 0.1, y: 1 }, { x: 0.9, y: 1 }, { x: 1, y: 0 }],
|
||||
}],
|
||||
imageWeightVersion: 1,
|
||||
name: 'ParticleSystem1',
|
||||
mode: 'stream',
|
||||
rate: 20,
|
||||
@@ -255,12 +306,57 @@ export function defaultConfig(): EmitterConfig {
|
||||
export function ensureEmitterConfig(config: EmitterConfig): EmitterConfig {
|
||||
const defaults = defaultConfig() as unknown as Record<string, unknown>
|
||||
const target = config as unknown as Record<string, unknown>
|
||||
const needsImageWeightMigration = target.imageWeightVersion !== 1
|
||||
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
|
||||
}
|
||||
if (!Array.isArray(config.imageResources) || config.imageResources.length === 0) {
|
||||
config.imageResources = defaultConfig().imageResources
|
||||
}
|
||||
for (const resource of config.imageResources) {
|
||||
if (!resource.texture && config.texture) resource.texture = config.texture
|
||||
if (!resource.textureName) resource.textureName = 'star'
|
||||
if (!resource.previewUrl) resource.previewUrl = resource.textureName === 'star' ? '/particles/star.png' : ''
|
||||
if (!Number.isFinite(resource.weight)) resource.weight = 100
|
||||
if (typeof resource.locked !== 'boolean') resource.locked = false
|
||||
if (!Number.isFinite(resource.scale)) resource.scale = 1
|
||||
if (!Number.isFinite(resource.anchorX)) resource.anchorX = 0.5
|
||||
if (!Number.isFinite(resource.anchorY)) resource.anchorY = 0.5
|
||||
if (resource.colorMode !== 'fixed' && resource.colorMode !== 'lifetime') {
|
||||
resource.colorMode = config.colorOverLife ? 'lifetime' : 'fixed'
|
||||
}
|
||||
if (!resource.colorStart) resource.colorStart = config.colorStart || '#ffffff'
|
||||
if (!resource.colorEnd) resource.colorEnd = config.colorEnd || resource.colorStart
|
||||
if (!Array.isArray(resource.colorGradient) || resource.colorGradient.length < 2) {
|
||||
resource.colorGradient = [
|
||||
{ position: 0, color: resource.colorStart },
|
||||
{ position: 1, color: resource.colorEnd },
|
||||
]
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(BLEND_MAP, resource.blend)) resource.blend = config.blend || 'normal'
|
||||
if (!['off', 'fixed', 'curve'].includes(resource.independentAlpha)) resource.independentAlpha = config.independentAlpha || 'off'
|
||||
if (!Number.isFinite(resource.alpha)) resource.alpha = Number.isFinite(config.alphaScale) ? config.alphaScale : 1
|
||||
if (!['跟随导出', '线性', '贝塞尔'].includes(resource.alphaCurveMode)) resource.alphaCurveMode = config.curveMode || '跟随导出'
|
||||
if (!Array.isArray(resource.alphaCurve) || resource.alphaCurve.length < 2) {
|
||||
resource.alphaCurve = (config.alphaCurve || [{ x: 0, y: 1 }, { x: 1, y: 1 }]).map((point) => ({ ...point }))
|
||||
}
|
||||
}
|
||||
if (config.imageResources.length === 1) {
|
||||
config.imageResources[0].weight = 100
|
||||
config.imageResources[0].locked = false
|
||||
} else if (needsImageWeightMigration) {
|
||||
const equalWeight = 100 / config.imageResources.length
|
||||
config.imageResources.forEach((resource, index) => {
|
||||
resource.weight = index === config.imageResources.length - 1
|
||||
? 100 - equalWeight * (config.imageResources.length - 1)
|
||||
: equalWeight
|
||||
resource.locked = false
|
||||
})
|
||||
}
|
||||
config.imageWeightVersion = 1
|
||||
return config
|
||||
}
|
||||
|
||||
@@ -278,6 +374,7 @@ interface Particle {
|
||||
speedScale: number
|
||||
speedScaleTarget: number
|
||||
scaleOverLifeX: number; scaleOverLifeY: number
|
||||
resourceId: number
|
||||
}
|
||||
|
||||
const BLEND_MAP: Record<Blend, number> = { normal: 0, add: 1, multiply: 2, screen: 3 }
|
||||
@@ -327,7 +424,7 @@ export class ParticleEmitter extends Container {
|
||||
sprite, active: false, boneName: 'p_' + this.pool.length,
|
||||
life: 0, maxLife: 1, x: 0, y: 0, vx: 0, vy: 0,
|
||||
scaleStartX: 1, scaleStartY: 1, alphaStart: 1, alphaEnd: 1, rotation: 0, rotationSpeed: 0,
|
||||
speedScale: 1, speedScaleTarget: 1, scaleOverLifeX: 1, scaleOverLifeY: 1,
|
||||
speedScale: 1, speedScaleTarget: 1, scaleOverLifeX: 1, scaleOverLifeY: 1, resourceId: 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -393,6 +490,7 @@ export class ParticleEmitter extends Container {
|
||||
p.y += p.vy * dt
|
||||
const t = 1 - p.life / p.maxLife
|
||||
const s = p.sprite
|
||||
const resource = cfg.imageResources.find((item) => item.id === p.resourceId)
|
||||
|
||||
if (cfg.speedOverLifeEnabled) {
|
||||
const target = finite(overLifeValue(cfg.speedOverLifeMode, cfg.speedOverLifeMin, cfg.speedOverLifeMax, cfg.speedOverLifeCurve, t, p.speedScaleTarget), 1)
|
||||
@@ -414,10 +512,22 @@ export class ParticleEmitter extends Container {
|
||||
: 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
|
||||
const rgb = cfg.colorOverLife ? blendRgb(cfg.colorStart, cfg.colorEnd, t) : hexToNumber(cfg.colorStart)
|
||||
const baseAlpha = cfg.alphaMode === 'curve'
|
||||
? curveAt(cfg.alphaCurve, t) * cfg.alphaScale
|
||||
: cfg.alpha * cfg.alphaScale
|
||||
// 开启卡片独立透明度后,覆盖系统透明度;固定值不会再受系统曲线影响。
|
||||
s.alpha = finite(resource?.independentAlpha === 'fixed'
|
||||
? resource.alpha
|
||||
: resource?.independentAlpha === 'curve'
|
||||
? curveAt(resource.alphaCurve, t)
|
||||
: baseAlpha, 1)
|
||||
const colorStart = resource?.colorStart || '#ffffff'
|
||||
const rgb = resource?.colorMode === 'lifetime'
|
||||
? gradientColorAt(resource.colorGradient, t)
|
||||
: hexToNumber(colorStart)
|
||||
s.tint = rgb
|
||||
states.push({ boneName: p.boneName, x: p.x, y: p.y, rotation: p.rotation, scaleX: s.scale.x, scaleY: s.scale.y, alpha: s.alpha, colorHex: rgb, active: true })
|
||||
s.blendMode = BLEND_MAP[resource?.blend || 'normal'] as any
|
||||
states.push({ boneName: p.boneName, x: p.x, y: p.y, rotation: p.rotation, scaleX: s.scale.x, scaleY: s.scale.y, alpha: s.alpha, colorHex: rgb, resourceId: p.resourceId, active: true })
|
||||
}
|
||||
this.states = states
|
||||
return states
|
||||
@@ -461,13 +571,14 @@ export class ParticleEmitter extends Container {
|
||||
dirAng = (cfg.direction + lerp(-cfg.spread / 2, cfg.spread / 2, this.rng())) * Math.PI / 180
|
||||
}
|
||||
const p = this.pool[idx]
|
||||
const resource = this.pickImageResource()
|
||||
p.active = true; p.life = life; p.maxLife = life
|
||||
p.x = ox; p.y = oy
|
||||
p.vx = Math.cos(dirAng) * speed
|
||||
p.vy = (cfg.shape === 'cone' ? -Math.sin(dirAng) : Math.sin(dirAng)) * speed
|
||||
p.scaleStartX = initialValue(cfg.scaleMode, cfg.scaleMin, cfg.scaleMax, cfg.scaleCurve, this.rng())
|
||||
p.scaleStartX = initialValue(cfg.scaleMode, cfg.scaleMin, cfg.scaleMax, cfg.scaleCurve, this.rng()) * resource.scale
|
||||
p.scaleStartY = cfg.scaleAxisMode === 'separate'
|
||||
? initialValue(cfg.scaleMode, cfg.scaleYMin, cfg.scaleYMax, cfg.scaleYCurve, this.rng())
|
||||
? initialValue(cfg.scaleMode, cfg.scaleYMin, cfg.scaleYMax, cfg.scaleYCurve, this.rng()) * resource.scale
|
||||
: p.scaleStartX
|
||||
p.alphaStart = cfg.alpha; p.alphaEnd = cfg.alphaEnd
|
||||
p.rotation = cfg.initialRotationMode === 'direction'
|
||||
@@ -486,10 +597,34 @@ export class ParticleEmitter extends Container {
|
||||
p.scaleOverLifeY = cfg.scaleOverLifeMode === 'random'
|
||||
? lerp(cfg.scaleYOverLifeMin, cfg.scaleYOverLifeMax, this.rng())
|
||||
: cfg.scaleYOverLifeMin
|
||||
p.sprite.tint = hexToNumber(cfg.colorStart)
|
||||
p.resourceId = resource.id
|
||||
p.sprite.texture = resource.texture || cfg.texture
|
||||
p.sprite.anchor.set(resource.anchorX, resource.anchorY)
|
||||
p.sprite.blendMode = BLEND_MAP[resource.blend] as any
|
||||
p.sprite.tint = hexToNumber(resource.colorStart)
|
||||
p.sprite.visible = true
|
||||
}
|
||||
|
||||
private pickImageResource(): ParticleImageResource {
|
||||
const resources = this.cfg.imageResources.filter((resource) => resource.texture)
|
||||
if (!resources.length) return {
|
||||
id: 1, texture: this.cfg.texture, textureName: 'star', textureFolder: '',
|
||||
previewUrl: '/particles/star.png', weight: 100, locked: false, scale: 1, anchorX: 0.5, anchorY: 0.5,
|
||||
colorMode: 'fixed', colorStart: '#ffffff', colorEnd: '#ff4d4d', blend: 'normal',
|
||||
colorGradient: [{ position: 0, color: '#ffffff' }, { position: 1, color: '#ff4d4d' }],
|
||||
independentAlpha: 'off', alpha: 1, alphaCurveMode: '跟随导出',
|
||||
alphaCurve: [{ x: 0, y: 0 }, { x: 0.1, y: 1 }, { x: 0.9, y: 1 }, { x: 1, y: 0 }],
|
||||
}
|
||||
const total = resources.reduce((sum, resource) => sum + Math.max(0, resource.weight), 0)
|
||||
if (total <= 0) return resources[Math.floor(this.rng() * resources.length)]
|
||||
let cursor = this.rng() * total
|
||||
for (const resource of resources) {
|
||||
cursor -= Math.max(0, resource.weight)
|
||||
if (cursor <= 0) return resource
|
||||
}
|
||||
return resources[resources.length - 1]
|
||||
}
|
||||
|
||||
private kill(i: number) {
|
||||
const p = this.pool[i]
|
||||
p.active = false
|
||||
@@ -514,7 +649,7 @@ export class ParticleEmitter extends Container {
|
||||
out.push({
|
||||
boneName: p.boneName, x: p.x, y: p.y, rotation: p.rotation,
|
||||
scaleX: p.sprite.scale.x, scaleY: p.sprite.scale.y,
|
||||
alpha: p.sprite.alpha, colorHex: p.sprite.tint as number, active: true,
|
||||
alpha: p.sprite.alpha, colorHex: p.sprite.tint as number, resourceId: p.resourceId, active: true,
|
||||
})
|
||||
}
|
||||
return out
|
||||
@@ -534,6 +669,14 @@ export class ParticleEmitter extends Container {
|
||||
s.scale.set(st.scaleX, st.scaleY)
|
||||
s.alpha = st.alpha
|
||||
s.tint = st.colorHex
|
||||
if (st.resourceId != null) {
|
||||
const resource = this.cfg.imageResources.find((item) => item.id === st.resourceId)
|
||||
if (resource?.texture) {
|
||||
s.texture = resource.texture
|
||||
s.anchor.set(resource.anchorX, resource.anchorY)
|
||||
s.blendMode = BLEND_MAP[resource.blend] as any
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -595,6 +738,19 @@ function blendRgb(start: string, end: string, t: number): number {
|
||||
const b = Math.round(s[2] + (e[2] - s[2]) * t)
|
||||
return (r << 16) | (g << 8) | b
|
||||
}
|
||||
function gradientColorAt(stops: ColorGradientStop[] | undefined, t: number): number {
|
||||
if (!Array.isArray(stops) || stops.length === 0) return 0xffffff
|
||||
const sorted = [...stops].sort((a, b) => a.position - b.position)
|
||||
if (t <= sorted[0].position) return hexToNumber(sorted[0].color)
|
||||
if (t >= sorted[sorted.length - 1].position) return hexToNumber(sorted[sorted.length - 1].color)
|
||||
for (let index = 0; index < sorted.length - 1; index++) {
|
||||
const start = sorted[index], end = sorted[index + 1]
|
||||
if (t < start.position || t > end.position) continue
|
||||
const amount = (t - start.position) / Math.max(0.0001, end.position - start.position)
|
||||
return blendRgb(start.color, end.color, amount)
|
||||
}
|
||||
return hexToNumber(sorted[sorted.length - 1].color)
|
||||
}
|
||||
function hexToRgb(hex: string): [number, number, number] {
|
||||
let h = hex.replace('#', '')
|
||||
if (h.length === 3) h = h.split('').map((c) => c + c).join('')
|
||||
|
||||
Reference in new issue
Block a user