资源与外观调整

This commit is contained in:
tianmo committed 2026-08-27 19:06:25 +08:00
1 parent 2ad0b76d11
commit f0f4104b0c
6 files changed
+684 -60

No files matched your search

+164 -8
View File
@@ -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('')