阶段五完成
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
+532
-7
@@ -1,4 +1,4 @@
|
|||||||
import { Container, Sprite, Texture, IPointData } from 'pixi.js'
|
import { Container, Sprite, Texture, IPointData, SimpleMesh, Graphics } from 'pixi.js'
|
||||||
|
|
||||||
/** 发射模式 */
|
/** 发射模式 */
|
||||||
export type EmitMode = 'stream' | 'burst'
|
export type EmitMode = 'stream' | 'burst'
|
||||||
@@ -39,6 +39,32 @@ export interface ParticleImageResource {
|
|||||||
alphaCurve: CurvePoint[]
|
alphaCurve: CurvePoint[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TrailImageResource {
|
||||||
|
id: number
|
||||||
|
texture: Texture<any> | null
|
||||||
|
textureName: string
|
||||||
|
previewUrl: string
|
||||||
|
weight: number
|
||||||
|
locked: boolean
|
||||||
|
/** 图片默认方向,修改后重新生成 UV 并绑定蒙皮 */
|
||||||
|
rotation: number
|
||||||
|
colorMode: 'particle' | 'fixed' | 'curve'
|
||||||
|
color: string
|
||||||
|
colorGradient: ColorGradientStop[]
|
||||||
|
blend: Blend
|
||||||
|
alphaMode: 'particle' | 'fixed' | 'curve'
|
||||||
|
alpha: number
|
||||||
|
alphaCurve: CurvePoint[]
|
||||||
|
boneCount: number
|
||||||
|
length: number
|
||||||
|
width: number
|
||||||
|
widthMode: 'particle' | 'fixed'
|
||||||
|
lifeLengthEnabled: boolean
|
||||||
|
lifeLengthCurve: CurvePoint[]
|
||||||
|
shapeEnabled: boolean
|
||||||
|
shapeCurve: CurvePoint[]
|
||||||
|
}
|
||||||
|
|
||||||
/** 粒子配置(引用保持,面板改字段即实时生效) */
|
/** 粒子配置(引用保持,面板改字段即实时生效) */
|
||||||
export interface EmitterConfig {
|
export interface EmitterConfig {
|
||||||
texture: Texture<any>
|
texture: Texture<any>
|
||||||
@@ -196,6 +222,32 @@ export interface EmitterConfig {
|
|||||||
attractionPath: 'linear' | 'curve'
|
attractionPath: 'linear' | 'curve'
|
||||||
/** 开启拖尾 */
|
/** 开启拖尾 */
|
||||||
trail: boolean
|
trail: boolean
|
||||||
|
trailConfigVersion: number
|
||||||
|
trailResources: TrailImageResource[]
|
||||||
|
trailResourceVersion: number
|
||||||
|
/** 拖尾使用独立图片和独立网格蒙皮 */
|
||||||
|
/** @deprecated 旧版单拖尾资源字段,仅用于配置迁移 */
|
||||||
|
trailTexture: Texture<any> | null
|
||||||
|
trailTextureName: string
|
||||||
|
trailPreviewUrl: string
|
||||||
|
trailColorMode: 'particle' | 'fixed' | 'curve'
|
||||||
|
trailColor: string
|
||||||
|
trailColorGradient: ColorGradientStop[]
|
||||||
|
trailAlphaMode: 'particle' | 'fixed' | 'curve'
|
||||||
|
trailAlpha: number
|
||||||
|
trailAlphaCurve: CurvePoint[]
|
||||||
|
/** 每个粒子的拖尾骨骼链数量 */
|
||||||
|
trailBoneCount: number
|
||||||
|
trailLength: number
|
||||||
|
trailWidth: number
|
||||||
|
trailWidthMode: 'particle' | 'fixed'
|
||||||
|
trailLifeLengthEnabled: boolean
|
||||||
|
trailLifeLengthCurve: CurvePoint[]
|
||||||
|
trailShapeEnabled: boolean
|
||||||
|
trailShapeCurve: CurvePoint[]
|
||||||
|
/** 蒙皮网格拓扑尺寸 */
|
||||||
|
trailGridRows: number
|
||||||
|
trailGridCols: number
|
||||||
/** 开启碰撞(预留) */
|
/** 开启碰撞(预留) */
|
||||||
collision: boolean
|
collision: boolean
|
||||||
/** 开启路径跟随(预留) */
|
/** 开启路径跟随(预留) */
|
||||||
@@ -214,9 +266,26 @@ export interface ParticleState {
|
|||||||
alpha: number
|
alpha: number
|
||||||
colorHex: number
|
colorHex: number
|
||||||
resourceId?: number
|
resourceId?: number
|
||||||
|
/** 拖尾拥有独立骨骼链和蒙皮状态,录制/回放时一并保存。 */
|
||||||
|
trailState?: TrailState
|
||||||
active: boolean
|
active: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TrailBoneState {
|
||||||
|
boneName: string
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
rotation: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrailState {
|
||||||
|
bones: TrailBoneState[]
|
||||||
|
width: number
|
||||||
|
alpha: number
|
||||||
|
colorHex: number
|
||||||
|
resourceId?: number
|
||||||
|
}
|
||||||
|
|
||||||
export function defaultConfig(): EmitterConfig {
|
export function defaultConfig(): EmitterConfig {
|
||||||
return {
|
return {
|
||||||
texture: null as unknown as Texture,
|
texture: null as unknown as Texture,
|
||||||
@@ -354,6 +423,51 @@ export function defaultConfig(): EmitterConfig {
|
|||||||
attractionDelayMode: 'particle',
|
attractionDelayMode: 'particle',
|
||||||
attractionPath: 'curve',
|
attractionPath: 'curve',
|
||||||
trail: false,
|
trail: false,
|
||||||
|
trailConfigVersion: 3,
|
||||||
|
trailResources: [{
|
||||||
|
id: 1,
|
||||||
|
texture: null,
|
||||||
|
textureName: 'trail',
|
||||||
|
previewUrl: '/trails/trail.png',
|
||||||
|
weight: 100,
|
||||||
|
locked: false,
|
||||||
|
rotation: 0,
|
||||||
|
colorMode: 'particle',
|
||||||
|
color: '#ffffff',
|
||||||
|
colorGradient: [{ position: 0, color: '#ffffff' }, { position: 1, color: '#ffffff' }],
|
||||||
|
blend: 'normal',
|
||||||
|
alphaMode: 'particle',
|
||||||
|
alpha: 1,
|
||||||
|
alphaCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
|
||||||
|
boneCount: 3,
|
||||||
|
length: 100,
|
||||||
|
width: 100,
|
||||||
|
widthMode: 'particle',
|
||||||
|
lifeLengthEnabled: false,
|
||||||
|
lifeLengthCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
|
||||||
|
shapeEnabled: false,
|
||||||
|
shapeCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
|
||||||
|
}],
|
||||||
|
trailResourceVersion: 5,
|
||||||
|
trailTexture: null,
|
||||||
|
trailTextureName: 'trail',
|
||||||
|
trailPreviewUrl: '/trails/trail.png',
|
||||||
|
trailColorMode: 'particle',
|
||||||
|
trailColor: '#ffffff',
|
||||||
|
trailColorGradient: [{ position: 0, color: '#ffffff' }, { position: 1, color: '#ffffff' }],
|
||||||
|
trailAlphaMode: 'particle',
|
||||||
|
trailAlpha: 1,
|
||||||
|
trailAlphaCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
|
||||||
|
trailBoneCount: 3,
|
||||||
|
trailLength: 100,
|
||||||
|
trailWidth: 100,
|
||||||
|
trailWidthMode: 'particle',
|
||||||
|
trailLifeLengthEnabled: false,
|
||||||
|
trailLifeLengthCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
|
||||||
|
trailShapeEnabled: false,
|
||||||
|
trailShapeCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
|
||||||
|
trailGridRows: 4,
|
||||||
|
trailGridCols: 4,
|
||||||
collision: false,
|
collision: false,
|
||||||
pathFollow: false,
|
pathFollow: false,
|
||||||
}
|
}
|
||||||
@@ -371,6 +485,12 @@ export function ensureEmitterConfig(config: EmitterConfig): EmitterConfig {
|
|||||||
const legacyWindY = Number(target.windY)
|
const legacyWindY = Number(target.windY)
|
||||||
const needsForceFieldMigration = target.forceFalloff == null
|
const needsForceFieldMigration = target.forceFalloff == null
|
||||||
const legacyForceStrength = Number(target.forceStrength)
|
const legacyForceStrength = Number(target.forceStrength)
|
||||||
|
const needsTrailCurveMigration = target.trailConfigVersion !== 3
|
||||||
|
const needsTrailResourceMigration = !Array.isArray(target.trailResources)
|
||||||
|
const needsTrailResourceStyleMigration = target.trailResourceVersion !== 5
|
||||||
|
const legacyTrailTexture = target.trailTexture as Texture<any> | null | undefined
|
||||||
|
const legacyTrailTextureName = String(target.trailTextureName || 'trail')
|
||||||
|
const legacyTrailPreviewUrl = String(target.trailPreviewUrl || '/trails/trail.png')
|
||||||
for (const [key, value] of Object.entries(defaults)) {
|
for (const [key, value] of Object.entries(defaults)) {
|
||||||
if (target[key] !== undefined && target[key] !== null) continue
|
if (target[key] !== undefined && target[key] !== null) continue
|
||||||
target[key] = Array.isArray(value)
|
target[key] = Array.isArray(value)
|
||||||
@@ -436,6 +556,80 @@ export function ensureEmitterConfig(config: EmitterConfig): EmitterConfig {
|
|||||||
if (needsForceFieldMigration) {
|
if (needsForceFieldMigration) {
|
||||||
config.forceStrength = Number.isFinite(legacyForceStrength) && legacyForceStrength !== 0 ? legacyForceStrength : 10
|
config.forceStrength = Number.isFinite(legacyForceStrength) && legacyForceStrength !== 0 ? legacyForceStrength : 10
|
||||||
}
|
}
|
||||||
|
if (needsTrailCurveMigration) {
|
||||||
|
config.trailConfigVersion = 3
|
||||||
|
config.trailAlphaCurve = [{ x: 0, y: 1 }, { x: 1, y: 1 }]
|
||||||
|
config.trailLifeLengthCurve = [{ x: 0, y: 1 }, { x: 1, y: 1 }]
|
||||||
|
config.trailShapeCurve = [{ x: 0, y: 1 }, { x: 1, y: 1 }]
|
||||||
|
config.trailGridRows = 4
|
||||||
|
config.trailGridCols = 4
|
||||||
|
}
|
||||||
|
if (needsTrailResourceMigration || !config.trailResources.length) {
|
||||||
|
config.trailResources = [{
|
||||||
|
id: 1,
|
||||||
|
texture: legacyTrailTexture || null,
|
||||||
|
textureName: legacyTrailTextureName,
|
||||||
|
previewUrl: legacyTrailPreviewUrl,
|
||||||
|
weight: 100,
|
||||||
|
locked: false,
|
||||||
|
rotation: 0,
|
||||||
|
colorMode: config.trailColorMode,
|
||||||
|
color: config.trailColor,
|
||||||
|
colorGradient: config.trailColorGradient.map((stop) => ({ ...stop })),
|
||||||
|
blend: 'normal',
|
||||||
|
alphaMode: config.trailAlphaMode,
|
||||||
|
alpha: config.trailAlpha,
|
||||||
|
alphaCurve: config.trailAlphaCurve.map((point) => ({ ...point })),
|
||||||
|
boneCount: config.trailBoneCount,
|
||||||
|
length: config.trailLength,
|
||||||
|
width: config.trailWidth,
|
||||||
|
widthMode: config.trailWidthMode,
|
||||||
|
lifeLengthEnabled: config.trailLifeLengthEnabled,
|
||||||
|
lifeLengthCurve: config.trailLifeLengthCurve.map((point) => ({ ...point })),
|
||||||
|
shapeEnabled: config.trailShapeEnabled,
|
||||||
|
shapeCurve: config.trailShapeCurve.map((point) => ({ ...point })),
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
for (const resource of config.trailResources) {
|
||||||
|
if (!resource.textureName) resource.textureName = 'trail'
|
||||||
|
if (!resource.previewUrl) resource.previewUrl = '/trails/trail.png'
|
||||||
|
if (!Number.isFinite(resource.weight)) resource.weight = 100
|
||||||
|
if (typeof resource.locked !== 'boolean') resource.locked = false
|
||||||
|
if (!Number.isFinite(resource.rotation)) resource.rotation = 0
|
||||||
|
if (!['particle', 'fixed', 'curve'].includes(resource.colorMode)) resource.colorMode = config.trailColorMode || 'particle'
|
||||||
|
if (!resource.color) resource.color = config.trailColor || '#ffffff'
|
||||||
|
if (!Array.isArray(resource.colorGradient) || resource.colorGradient.length < 2) {
|
||||||
|
resource.colorGradient = (config.trailColorGradient || [{ position: 0, color: '#ffffff' }, { position: 1, color: '#ffffff' }]).map((stop) => ({ ...stop }))
|
||||||
|
}
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(BLEND_MAP, resource.blend)) resource.blend = 'normal'
|
||||||
|
if (!['particle', 'fixed', 'curve'].includes(resource.alphaMode)) resource.alphaMode = config.trailAlphaMode || 'particle'
|
||||||
|
if (!Number.isFinite(resource.alpha)) resource.alpha = Number.isFinite(config.trailAlpha) ? config.trailAlpha : 1
|
||||||
|
if (!Array.isArray(resource.alphaCurve) || resource.alphaCurve.length < 2) {
|
||||||
|
resource.alphaCurve = (config.trailAlphaCurve || [{ x: 0, y: 1 }, { x: 1, y: 1 }]).map((point) => ({ ...point }))
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(resource.boneCount)) resource.boneCount = Number.isFinite(config.trailBoneCount) ? config.trailBoneCount : 3
|
||||||
|
if (!Number.isFinite(resource.length)) resource.length = Number.isFinite(config.trailLength) ? config.trailLength : 100
|
||||||
|
if (!Number.isFinite(resource.width)) resource.width = Number.isFinite(config.trailWidth) ? config.trailWidth : 100
|
||||||
|
if (!['particle', 'fixed'].includes(resource.widthMode)) resource.widthMode = config.trailWidthMode || 'particle'
|
||||||
|
if (typeof resource.lifeLengthEnabled !== 'boolean') resource.lifeLengthEnabled = Boolean(config.trailLifeLengthEnabled)
|
||||||
|
if (!Array.isArray(resource.lifeLengthCurve) || resource.lifeLengthCurve.length < 2) {
|
||||||
|
resource.lifeLengthCurve = (config.trailLifeLengthCurve || [{ x: 0, y: 1 }, { x: 1, y: 1 }]).map((point) => ({ ...point }))
|
||||||
|
}
|
||||||
|
if (typeof resource.shapeEnabled !== 'boolean') resource.shapeEnabled = Boolean(config.trailShapeEnabled)
|
||||||
|
if (!Array.isArray(resource.shapeCurve) || resource.shapeCurve.length < 2) {
|
||||||
|
resource.shapeCurve = (config.trailShapeCurve || [{ x: 0, y: 1 }, { x: 1, y: 1 }]).map((point) => ({ ...point }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (config.trailResources.length === 1) {
|
||||||
|
config.trailResources[0].weight = 100
|
||||||
|
config.trailResources[0].locked = false
|
||||||
|
}
|
||||||
|
if (needsTrailResourceStyleMigration) {
|
||||||
|
for (const resource of config.trailResources) {
|
||||||
|
resource.alphaCurve = resource.alphaCurve?.length >= 2 ? resource.alphaCurve : [{ x: 0, y: 1 }, { x: 1, y: 1 }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
config.trailResourceVersion = 5
|
||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,6 +655,15 @@ interface Particle {
|
|||||||
attractionStartWorldX: number
|
attractionStartWorldX: number
|
||||||
attractionStartWorldY: number
|
attractionStartWorldY: number
|
||||||
attractionCurveSide: number
|
attractionCurveSide: number
|
||||||
|
trailMesh: SimpleMesh | null
|
||||||
|
trailMeshSignature: string
|
||||||
|
trailHistory: { x: number; y: number }[]
|
||||||
|
trailBones: TrailBoneState[]
|
||||||
|
trailSkinWeights: { boneA: number; boneB: number; weight: number }[]
|
||||||
|
trailCurrentWidth: number
|
||||||
|
trailCurrentAlpha: number
|
||||||
|
trailCurrentColor: number
|
||||||
|
trailResourceId: number
|
||||||
resourceId: number
|
resourceId: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,6 +676,7 @@ export class ParticleEmitter extends Container {
|
|||||||
private cfg: EmitterConfig
|
private cfg: EmitterConfig
|
||||||
private accumulator = 0
|
private accumulator = 0
|
||||||
private burstTimer = 0
|
private burstTimer = 0
|
||||||
|
private _spawnSerial = 0
|
||||||
/** 发射器累计运行时间(秒) — 非响应式,避免污染 config */
|
/** 发射器累计运行时间(秒) — 非响应式,避免污染 config */
|
||||||
private _elapsed = 0
|
private _elapsed = 0
|
||||||
/** 用于“统一时间”延迟:记录吸附从关闭切换为开启时的发射器时间。 */
|
/** 用于“统一时间”延迟:记录吸附从关闭切换为开启时的发射器时间。 */
|
||||||
@@ -484,11 +688,13 @@ export class ParticleEmitter extends Container {
|
|||||||
/** 确定性随机(种子>0 时可复现) */
|
/** 确定性随机(种子>0 时可复现) */
|
||||||
private rng: () => number = Math.random
|
private rng: () => number = Math.random
|
||||||
private _lastSeed = -1
|
private _lastSeed = -1
|
||||||
|
private trailDebugGfx = new Graphics()
|
||||||
|
|
||||||
constructor(config: EmitterConfig, maxParticles = MAX_PARTICLES) {
|
constructor(config: EmitterConfig, maxParticles = MAX_PARTICLES) {
|
||||||
super()
|
super()
|
||||||
this.cfg = ensureEmitterConfig(config)
|
this.cfg = ensureEmitterConfig(config)
|
||||||
this._ensurePool(maxParticles)
|
this._ensurePool(maxParticles)
|
||||||
|
this.addChild(this.trailDebugGfx)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 仅在种子变化时重建随机源;种子不变时让序列持续推进,保证"确定性随机序列"语义 */
|
/** 仅在种子变化时重建随机源;种子不变时让序列持续推进,保证"确定性随机序列"语义 */
|
||||||
@@ -521,6 +727,15 @@ export class ParticleEmitter extends Container {
|
|||||||
attractionStartWorldX: 0,
|
attractionStartWorldX: 0,
|
||||||
attractionStartWorldY: 0,
|
attractionStartWorldY: 0,
|
||||||
attractionCurveSide: 1,
|
attractionCurveSide: 1,
|
||||||
|
trailMesh: null,
|
||||||
|
trailMeshSignature: '',
|
||||||
|
trailHistory: [],
|
||||||
|
trailBones: [],
|
||||||
|
trailSkinWeights: [],
|
||||||
|
trailCurrentWidth: 0,
|
||||||
|
trailCurrentAlpha: 0,
|
||||||
|
trailCurrentColor: 0xffffff,
|
||||||
|
trailResourceId: 1,
|
||||||
resourceId: 1,
|
resourceId: 1,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -536,7 +751,7 @@ export class ParticleEmitter extends Container {
|
|||||||
cfg.lifeMode == null || cfg.speedMode == null || cfg.scaleMode == null ||
|
cfg.lifeMode == null || cfg.speedMode == null || cfg.scaleMode == null ||
|
||||||
cfg.initialRotationMode == null || cfg.speedOverLifeEnabled == null ||
|
cfg.initialRotationMode == null || cfg.speedOverLifeEnabled == null ||
|
||||||
cfg.scaleOverLifeEnabled == null || cfg.rotationOverLifeEnabled == null ||
|
cfg.scaleOverLifeEnabled == null || cfg.rotationOverLifeEnabled == null ||
|
||||||
!Array.isArray(cfg.scaleOverLifeCurve) || cfg.attraction == null
|
!Array.isArray(cfg.scaleOverLifeCurve) || cfg.attraction == null || cfg.trailBoneCount == null
|
||||||
) ensureEmitterConfig(cfg)
|
) ensureEmitterConfig(cfg)
|
||||||
this.reseed() // 种子变化即重建随机源
|
this.reseed() // 种子变化即重建随机源
|
||||||
// 累计发射时间
|
// 累计发射时间
|
||||||
@@ -725,12 +940,254 @@ export class ParticleEmitter extends Container {
|
|||||||
: hexToNumber(colorStart)
|
: hexToNumber(colorStart)
|
||||||
s.tint = rgb
|
s.tint = rgb
|
||||||
s.blendMode = BLEND_MAP[resource?.blend || 'normal'] as any
|
s.blendMode = BLEND_MAP[resource?.blend || 'normal'] as any
|
||||||
states.push({ boneName: p.boneName, x: renderX, y: renderY, rotation: p.rotation, scaleX: s.scale.x, scaleY: s.scale.y, alpha: s.alpha, colorHex: rgb, resourceId: p.resourceId, active: true })
|
const trailState = this.updateTrail(p, renderX, renderY, t, s)
|
||||||
|
states.push({ boneName: p.boneName, x: renderX, y: renderY, rotation: p.rotation, scaleX: s.scale.x, scaleY: s.scale.y, alpha: s.alpha, colorHex: rgb, resourceId: p.resourceId, trailState, active: true })
|
||||||
}
|
}
|
||||||
this.states = states
|
this.states = states
|
||||||
return states
|
return states
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 根据网格行列生成拓扑,并为每个顶点绑定相邻两根拖尾骨骼。 */
|
||||||
|
private ensureTrailMesh(p: Particle) {
|
||||||
|
const cfg = this.cfg
|
||||||
|
const trailResource = cfg.trailResources.find((resource) => resource.id === p.trailResourceId) || cfg.trailResources[0]
|
||||||
|
const activeTrailTexture = trailResource?.texture || Texture.EMPTY
|
||||||
|
const textureRotation = Number(trailResource?.rotation) || 0
|
||||||
|
const rows = Math.max(2, Math.min(16, Math.round(cfg.trailGridRows)))
|
||||||
|
const cols = Math.max(2, Math.min(32, Math.round(cfg.trailGridCols)))
|
||||||
|
const bones = Math.max(2, Math.min(16, Math.round(trailResource?.boneCount ?? 3)))
|
||||||
|
const signature = `${rows}:${cols}:${bones}:${trailResource?.id ?? 0}:${textureRotation}`
|
||||||
|
if (p.trailMesh && p.trailMeshSignature === signature) {
|
||||||
|
if (p.trailMesh.texture !== activeTrailTexture) p.trailMesh.texture = activeTrailTexture
|
||||||
|
return p.trailMesh
|
||||||
|
}
|
||||||
|
if (p.trailMesh) {
|
||||||
|
this.removeChild(p.trailMesh)
|
||||||
|
p.trailMesh.destroy()
|
||||||
|
}
|
||||||
|
// 拓扑或骨骼数量变化后丢弃旧轨迹,避免新权重继续套用旧骨骼位置产生偏移。
|
||||||
|
if (p.trailMeshSignature && p.trailMeshSignature !== signature) {
|
||||||
|
p.trailHistory.length = 0
|
||||||
|
p.trailBones.length = 0
|
||||||
|
}
|
||||||
|
const vertices = new Float32Array(rows * cols * 2)
|
||||||
|
const uvs = new Float32Array(rows * cols * 2)
|
||||||
|
const indices: number[] = []
|
||||||
|
const weights: { boneA: number; boneB: number; weight: number }[] = []
|
||||||
|
for (let col = 0; col < cols; col++) {
|
||||||
|
const columnT = col / (cols - 1)
|
||||||
|
const bonePosition = columnT * (bones - 1)
|
||||||
|
const boneA = Math.min(bones - 1, Math.floor(bonePosition))
|
||||||
|
const boneB = Math.min(bones - 1, boneA + 1)
|
||||||
|
const weight = bonePosition - boneA
|
||||||
|
for (let row = 0; row < rows; row++) {
|
||||||
|
const vertex = col * rows + row
|
||||||
|
// 骨骼链第 0 列是粒子头部;拖尾图片左侧为头。资源旋转绕 UV 中心重新绑定。
|
||||||
|
const sourceU = columnT
|
||||||
|
const sourceV = row / (rows - 1)
|
||||||
|
const angle = textureRotation * Math.PI / 180
|
||||||
|
const cos = Math.cos(angle), sin = Math.sin(angle)
|
||||||
|
uvs[vertex * 2] = 0.5 + cos * (sourceU - 0.5) - sin * (sourceV - 0.5)
|
||||||
|
uvs[vertex * 2 + 1] = 0.5 + sin * (sourceU - 0.5) + cos * (sourceV - 0.5)
|
||||||
|
weights.push({ boneA, boneB, weight })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let col = 0; col < cols - 1; col++) {
|
||||||
|
for (let row = 0; row < rows - 1; row++) {
|
||||||
|
const a = col * rows + row
|
||||||
|
const b = (col + 1) * rows + row
|
||||||
|
const c = a + 1
|
||||||
|
const d = b + 1
|
||||||
|
indices.push(a, b, c, b, d, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const mesh = new SimpleMesh(activeTrailTexture, vertices as any, uvs as any, new Uint16Array(indices) as any)
|
||||||
|
mesh.visible = false
|
||||||
|
mesh.blendMode = BLEND_MAP.normal as any
|
||||||
|
// 拖尾网格必须在粒子图片下方绘制。
|
||||||
|
this.addChildAt(mesh, Math.max(0, this.getChildIndex(p.sprite)))
|
||||||
|
p.trailMesh = mesh
|
||||||
|
p.trailMeshSignature = signature
|
||||||
|
p.trailSkinWeights = weights
|
||||||
|
return mesh
|
||||||
|
}
|
||||||
|
|
||||||
|
private appendTrailPoint(p: Particle, x: number, y: number, configuredLength: number) {
|
||||||
|
const first = p.trailHistory[0]
|
||||||
|
if (!first || Math.hypot(first.x - x, first.y - y) > 0.05) p.trailHistory.unshift({ x, y })
|
||||||
|
else { first.x = x; first.y = y }
|
||||||
|
// 保留略长于最大配置长度的轨迹,生命周期曲线从短变长时不会突然断裂。
|
||||||
|
const keepDistance = Math.max(1, configuredLength) * 1.5
|
||||||
|
let distance = 0
|
||||||
|
let keepCount = p.trailHistory.length
|
||||||
|
for (let i = 1; i < p.trailHistory.length; i++) {
|
||||||
|
distance += Math.hypot(p.trailHistory[i].x - p.trailHistory[i - 1].x, p.trailHistory[i].y - p.trailHistory[i - 1].y)
|
||||||
|
if (distance >= keepDistance || i >= 239) { keepCount = i + 1; break }
|
||||||
|
}
|
||||||
|
if (p.trailHistory.length > keepCount) p.trailHistory.length = keepCount
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 沿历史轨迹等距生成独立骨骼链;第 0 根骨骼位于粒子头部。 */
|
||||||
|
private sampleTrailBones(p: Particle, length: number, boneCount: number) {
|
||||||
|
const count = Math.max(2, Math.min(16, Math.round(boneCount)))
|
||||||
|
const history = p.trailHistory
|
||||||
|
const result: TrailBoneState[] = []
|
||||||
|
if (!history.length) return result
|
||||||
|
for (let boneIndex = 0; boneIndex < count; boneIndex++) {
|
||||||
|
const targetDistance = length * boneIndex / (count - 1)
|
||||||
|
let traversed = 0
|
||||||
|
let point = history[history.length - 1]
|
||||||
|
for (let i = 1; i < history.length; i++) {
|
||||||
|
const previous = history[i - 1]
|
||||||
|
const next = history[i]
|
||||||
|
const segment = Math.hypot(next.x - previous.x, next.y - previous.y)
|
||||||
|
if (traversed + segment >= targetDistance && segment > 0.0001) {
|
||||||
|
const amount = (targetDistance - traversed) / segment
|
||||||
|
point = { x: lerp(previous.x, next.x, amount), y: lerp(previous.y, next.y, amount) }
|
||||||
|
break
|
||||||
|
}
|
||||||
|
traversed += segment
|
||||||
|
}
|
||||||
|
result.push({ boneName: `${p.boneName}_trail_${boneIndex}`, x: point.x, y: point.y, rotation: 0 })
|
||||||
|
}
|
||||||
|
for (let i = 0; i < result.length; i++) {
|
||||||
|
const next = result[Math.min(result.length - 1, i + 1)]
|
||||||
|
const previous = result[Math.max(0, i - 1)]
|
||||||
|
const dx = i < result.length - 1 ? next.x - result[i].x : result[i].x - previous.x
|
||||||
|
const dy = i < result.length - 1 ? next.y - result[i].y : result[i].y - previous.y
|
||||||
|
result[i].rotation = Math.atan2(dy, dx) * 180 / Math.PI
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 由骨骼位置和顶点权重完成 CPU 蒙皮,更新 Pixi 网格。 */
|
||||||
|
private skinTrailMesh(p: Particle, state: TrailState) {
|
||||||
|
const mesh = this.ensureTrailMesh(p)
|
||||||
|
const cfg = this.cfg
|
||||||
|
const trailResource = cfg.trailResources.find((resource) => resource.id === state.resourceId) || cfg.trailResources[0]
|
||||||
|
if (!trailResource?.texture || state.bones.length < 2) { mesh.visible = false; return }
|
||||||
|
p.trailResourceId = trailResource.id
|
||||||
|
if (mesh.texture !== trailResource.texture) mesh.texture = trailResource.texture
|
||||||
|
const rows = Math.max(2, Math.min(16, Math.round(cfg.trailGridRows)))
|
||||||
|
const cols = Math.max(2, Math.min(32, Math.round(cfg.trailGridCols)))
|
||||||
|
const vertices = mesh.vertices as unknown as Float32Array
|
||||||
|
const centers: { x: number; y: number }[] = []
|
||||||
|
for (let col = 0; col < cols; col++) {
|
||||||
|
const skin = p.trailSkinWeights[col * rows]
|
||||||
|
const a = state.bones[Math.min(state.bones.length - 1, skin.boneA)]
|
||||||
|
const b = state.bones[Math.min(state.bones.length - 1, skin.boneB)]
|
||||||
|
centers.push({ x: lerp(a.x, b.x, skin.weight), y: lerp(a.y, b.y, skin.weight) })
|
||||||
|
}
|
||||||
|
for (let col = 0; col < cols; col++) {
|
||||||
|
const before = centers[Math.max(0, col - 1)]
|
||||||
|
const after = centers[Math.min(cols - 1, col + 1)]
|
||||||
|
const dx = after.x - before.x
|
||||||
|
const dy = after.y - before.y
|
||||||
|
const tangentLength = Math.max(0.0001, Math.hypot(dx, dy))
|
||||||
|
const normalX = -dy / tangentLength
|
||||||
|
const normalY = dx / tangentLength
|
||||||
|
const columnT = col / (cols - 1)
|
||||||
|
const shape = trailResource.shapeEnabled ? Math.max(0, curveAt(trailResource.shapeCurve, columnT)) : 1
|
||||||
|
for (let row = 0; row < rows; row++) {
|
||||||
|
const rowT = row / (rows - 1) - 0.5
|
||||||
|
const offset = rowT * state.width * shape
|
||||||
|
const vertex = (col * rows + row) * 2
|
||||||
|
vertices[vertex] = centers[col].x + normalX * offset
|
||||||
|
vertices[vertex + 1] = centers[col].y + normalY * offset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mesh.tint = state.colorHex
|
||||||
|
mesh.alpha = state.alpha
|
||||||
|
mesh.blendMode = BLEND_MAP[trailResource.blend] as any
|
||||||
|
mesh.visible = Math.hypot(centers[0].x - centers[cols - 1].x, centers[0].y - centers[cols - 1].y) > 0.5 && state.alpha > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateTrail(p: Particle, renderX: number, renderY: number, lifeT: number, sprite: Sprite): TrailState | undefined {
|
||||||
|
const cfg = this.cfg
|
||||||
|
if (!cfg.trail) {
|
||||||
|
p.trailHistory.length = 0
|
||||||
|
p.trailBones.length = 0
|
||||||
|
if (p.trailMesh) p.trailMesh.visible = false
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
// 先确认骨骼/网格签名;发生结构变化时会在记录新轨迹前完成清理和重新绑定。
|
||||||
|
this.ensureTrailMesh(p)
|
||||||
|
const trailResource = cfg.trailResources.find((resource) => resource.id === p.trailResourceId) || cfg.trailResources[0]
|
||||||
|
this.appendTrailPoint(p, renderX, renderY, trailResource?.length ?? 100)
|
||||||
|
const lengthMultiplier = trailResource?.lifeLengthEnabled ? Math.max(0, curveAt(trailResource.lifeLengthCurve, lifeT)) : 1
|
||||||
|
const length = Math.max(0, trailResource?.length ?? 100) * lengthMultiplier
|
||||||
|
p.trailBones = this.sampleTrailBones(p, length, trailResource?.boneCount ?? 3)
|
||||||
|
// 首骨骼必须与粒子最终渲染位置完全一致,避免采样/重绑时出现亚像素脱节。
|
||||||
|
if (p.trailBones[0]) {
|
||||||
|
p.trailBones[0].x = renderX
|
||||||
|
p.trailBones[0].y = renderY
|
||||||
|
}
|
||||||
|
const particleWidthScale = trailResource?.widthMode === 'particle'
|
||||||
|
? (Math.abs(sprite.scale.x) + Math.abs(sprite.scale.y)) * 0.5
|
||||||
|
: 1
|
||||||
|
p.trailCurrentWidth = Math.max(0, trailResource?.width ?? 100) * particleWidthScale
|
||||||
|
p.trailCurrentAlpha = trailResource?.alphaMode === 'particle'
|
||||||
|
? sprite.alpha
|
||||||
|
: trailResource?.alphaMode === 'curve'
|
||||||
|
? curveAt(trailResource.alphaCurve, lifeT)
|
||||||
|
: trailResource?.alpha ?? 1
|
||||||
|
p.trailCurrentColor = trailResource?.colorMode === 'particle'
|
||||||
|
? sprite.tint as number
|
||||||
|
: trailResource?.colorMode === 'curve'
|
||||||
|
? gradientColorAt(trailResource.colorGradient, lifeT)
|
||||||
|
: hexToNumber(trailResource?.color || '#ffffff')
|
||||||
|
const state: TrailState = {
|
||||||
|
bones: p.trailBones.map((bone) => ({ ...bone })),
|
||||||
|
width: p.trailCurrentWidth,
|
||||||
|
alpha: Math.min(1, Math.max(0, p.trailCurrentAlpha)),
|
||||||
|
colorHex: p.trailCurrentColor,
|
||||||
|
resourceId: p.trailResourceId,
|
||||||
|
}
|
||||||
|
this.skinTrailMesh(p, state)
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在画布上绘制实际蒙皮网格;仅用于编辑器调试显示,不参与录制或导出。 */
|
||||||
|
drawTrailMeshDebug(visible: boolean) {
|
||||||
|
const g = this.trailDebugGfx
|
||||||
|
g.clear()
|
||||||
|
if (!visible || !this.cfg.trail) return
|
||||||
|
const rows = Math.max(2, Math.min(16, Math.round(this.cfg.trailGridRows)))
|
||||||
|
const cols = Math.max(2, Math.min(32, Math.round(this.cfg.trailGridCols)))
|
||||||
|
g.lineStyle(1, 0x39d7ff, 0.72)
|
||||||
|
for (const p of this.pool) {
|
||||||
|
const mesh = p.trailMesh
|
||||||
|
if (!mesh?.visible) continue
|
||||||
|
const vertices = mesh.vertices as unknown as Float32Array
|
||||||
|
const point = (col: number, row: number) => {
|
||||||
|
const index = (col * rows + row) * 2
|
||||||
|
return { x: vertices[index], y: vertices[index + 1] }
|
||||||
|
}
|
||||||
|
for (let row = 0; row < rows; row++) {
|
||||||
|
for (let col = 0; col < cols; col++) {
|
||||||
|
const current = point(col, row)
|
||||||
|
if (col === 0) g.moveTo(current.x, current.y)
|
||||||
|
else g.lineTo(current.x, current.y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let col = 0; col < cols; col++) {
|
||||||
|
for (let row = 0; row < rows; row++) {
|
||||||
|
const current = point(col, row)
|
||||||
|
if (row === 0) g.moveTo(current.x, current.y)
|
||||||
|
else g.lineTo(current.x, current.y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g.beginFill(0x8cecff, 0.9)
|
||||||
|
for (let col = 0; col < cols; col++) {
|
||||||
|
for (let row = 0; row < rows; row++) {
|
||||||
|
const current = point(col, row)
|
||||||
|
g.drawCircle(current.x, current.y, 1.6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g.endFill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private emitBurst() {
|
private emitBurst() {
|
||||||
for (let i = 0; i < this.cfg.burstCount; i++) this.spawnOne()
|
for (let i = 0; i < this.cfg.burstCount; i++) this.spawnOne()
|
||||||
}
|
}
|
||||||
@@ -770,6 +1227,8 @@ export class ParticleEmitter extends Container {
|
|||||||
}
|
}
|
||||||
const p = this.pool[idx]
|
const p = this.pool[idx]
|
||||||
const resource = this.pickImageResource()
|
const resource = this.pickImageResource()
|
||||||
|
// 拖尾资源使用独立的确定性采样,不改变粒子属性原有随机序列。
|
||||||
|
const trailResource = this.pickTrailResource(seededUnit(cfg.seed, this._spawnSerial++, 0x5bd1e995))
|
||||||
// 噪声相位由全局随机种子和粒子池编号确定,同一配置重复录制得到相同轨迹。
|
// 噪声相位由全局随机种子和粒子池编号确定,同一配置重复录制得到相同轨迹。
|
||||||
p.noisePhaseX = seededUnit(cfg.seed, idx, 0x68bc21eb) * Math.PI * 2
|
p.noisePhaseX = seededUnit(cfg.seed, idx, 0x68bc21eb) * Math.PI * 2
|
||||||
p.noisePhaseY = seededUnit(cfg.seed, idx, 0x02e5be93) * Math.PI * 2
|
p.noisePhaseY = seededUnit(cfg.seed, idx, 0x02e5be93) * Math.PI * 2
|
||||||
@@ -782,6 +1241,13 @@ export class ParticleEmitter extends Container {
|
|||||||
p.attractionActive = false
|
p.attractionActive = false
|
||||||
p.attractionElapsed = 0
|
p.attractionElapsed = 0
|
||||||
p.attractionCurveSide = seededUnit(cfg.seed, idx, 0xa341316c) < 0.5 ? -1 : 1
|
p.attractionCurveSide = seededUnit(cfg.seed, idx, 0xa341316c) < 0.5 ? -1 : 1
|
||||||
|
p.trailHistory.length = 0
|
||||||
|
p.trailBones.length = 0
|
||||||
|
p.trailCurrentWidth = 0
|
||||||
|
p.trailCurrentAlpha = 0
|
||||||
|
p.trailCurrentColor = 0xffffff
|
||||||
|
p.trailResourceId = trailResource.id
|
||||||
|
if (p.trailMesh) p.trailMesh.visible = false
|
||||||
p.active = true; p.life = life; p.maxLife = life
|
p.active = true; p.life = life; p.maxLife = life
|
||||||
p.x = ox; p.y = oy
|
p.x = ox; p.y = oy
|
||||||
p.vx = Math.cos(dirAng) * speed
|
p.vx = Math.cos(dirAng) * speed
|
||||||
@@ -835,14 +1301,45 @@ export class ParticleEmitter extends Container {
|
|||||||
return resources[resources.length - 1]
|
return resources[resources.length - 1]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private pickTrailResource(sample: number): TrailImageResource {
|
||||||
|
const resources = this.cfg.trailResources.filter((resource) => resource.texture)
|
||||||
|
if (!resources.length) return this.cfg.trailResources[0] || {
|
||||||
|
id: 1, texture: null, textureName: 'trail', previewUrl: '/trails/trail.png', weight: 100, locked: false,
|
||||||
|
rotation: 0, colorMode: 'particle', color: '#ffffff',
|
||||||
|
colorGradient: [{ position: 0, color: '#ffffff' }, { position: 1, color: '#ffffff' }],
|
||||||
|
blend: 'normal',
|
||||||
|
alphaMode: 'particle', alpha: 1, alphaCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
|
||||||
|
boneCount: 3, length: 100, width: 100, widthMode: 'particle',
|
||||||
|
lifeLengthEnabled: false, lifeLengthCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
|
||||||
|
shapeEnabled: false, shapeCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
|
||||||
|
}
|
||||||
|
const total = resources.reduce((sum, resource) => sum + Math.max(0, resource.weight), 0)
|
||||||
|
if (total <= 0) return resources[Math.min(resources.length - 1, Math.floor(sample * resources.length))]
|
||||||
|
let cursor = sample * 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) {
|
private kill(i: number) {
|
||||||
const p = this.pool[i]
|
const p = this.pool[i]
|
||||||
p.active = false
|
p.active = false
|
||||||
p.sprite.visible = false
|
p.sprite.visible = false
|
||||||
|
p.trailHistory.length = 0
|
||||||
|
p.trailBones.length = 0
|
||||||
|
if (p.trailMesh) p.trailMesh.visible = false
|
||||||
}
|
}
|
||||||
|
|
||||||
clear() {
|
clear() {
|
||||||
for (const p of this.pool) { p.active = false; p.sprite.visible = false }
|
for (const p of this.pool) {
|
||||||
|
p.active = false
|
||||||
|
p.sprite.visible = false
|
||||||
|
p.trailHistory.length = 0
|
||||||
|
p.trailBones.length = 0
|
||||||
|
if (p.trailMesh) p.trailMesh.visible = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get activeCount() {
|
get activeCount() {
|
||||||
@@ -857,9 +1354,19 @@ export class ParticleEmitter extends Container {
|
|||||||
for (const p of this.pool) {
|
for (const p of this.pool) {
|
||||||
if (!p.active) continue
|
if (!p.active) continue
|
||||||
out.push({
|
out.push({
|
||||||
boneName: p.boneName, x: p.x, y: p.y, rotation: p.rotation,
|
// 拖尾记录的是最终渲染轨迹,因此粒子本体也必须记录 sprite 坐标(包含移动噪声等渲染偏移)。
|
||||||
|
boneName: p.boneName, x: p.sprite.x, y: p.sprite.y, rotation: p.rotation,
|
||||||
scaleX: p.sprite.scale.x, scaleY: p.sprite.scale.y,
|
scaleX: p.sprite.scale.x, scaleY: p.sprite.scale.y,
|
||||||
alpha: p.sprite.alpha, colorHex: p.sprite.tint as number, resourceId: p.resourceId, active: true,
|
alpha: p.sprite.alpha, colorHex: p.sprite.tint as number, resourceId: p.resourceId, active: true,
|
||||||
|
trailState: this.cfg.trail && p.trailBones.length
|
||||||
|
? {
|
||||||
|
bones: p.trailBones.map((bone) => ({ ...bone })),
|
||||||
|
width: p.trailCurrentWidth,
|
||||||
|
alpha: p.trailCurrentAlpha,
|
||||||
|
colorHex: p.trailCurrentColor,
|
||||||
|
resourceId: p.trailResourceId,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
@@ -871,7 +1378,11 @@ export class ParticleEmitter extends Container {
|
|||||||
for (const s of states) byName.set(s.boneName, s)
|
for (const s of states) byName.set(s.boneName, s)
|
||||||
for (const p of this.pool) {
|
for (const p of this.pool) {
|
||||||
const st = byName.get(p.boneName)
|
const st = byName.get(p.boneName)
|
||||||
if (!st) { p.sprite.visible = false; continue }
|
if (!st) {
|
||||||
|
p.sprite.visible = false
|
||||||
|
if (p.trailMesh) p.trailMesh.visible = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
const s = p.sprite
|
const s = p.sprite
|
||||||
s.visible = true
|
s.visible = true
|
||||||
s.x = st.x; s.y = st.y
|
s.x = st.x; s.y = st.y
|
||||||
@@ -887,14 +1398,28 @@ export class ParticleEmitter extends Container {
|
|||||||
s.blendMode = BLEND_MAP[resource.blend] as any
|
s.blendMode = BLEND_MAP[resource.blend] as any
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (this.cfg.trail && st.trailState) {
|
||||||
|
p.trailResourceId = st.trailState.resourceId ?? this.cfg.trailResources[0]?.id ?? 1
|
||||||
|
p.trailBones = st.trailState.bones.map((bone) => ({ ...bone }))
|
||||||
|
p.trailCurrentWidth = st.trailState.width
|
||||||
|
p.trailCurrentAlpha = st.trailState.alpha
|
||||||
|
this.skinTrailMesh(p, st.trailState)
|
||||||
|
} else if (p.trailMesh) p.trailMesh.visible = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 清空所有粒子并重置发射计时 */
|
/** 清空所有粒子并重置发射计时 */
|
||||||
reset() {
|
reset() {
|
||||||
for (const p of this.pool) { p.active = false; p.sprite.visible = false }
|
for (const p of this.pool) {
|
||||||
|
p.active = false
|
||||||
|
p.sprite.visible = false
|
||||||
|
p.trailHistory.length = 0
|
||||||
|
p.trailBones.length = 0
|
||||||
|
if (p.trailMesh) p.trailMesh.visible = false
|
||||||
|
}
|
||||||
this.accumulator = 0
|
this.accumulator = 0
|
||||||
this.burstTimer = 0
|
this.burstTimer = 0
|
||||||
|
this._spawnSerial = 0
|
||||||
this._elapsed = 0
|
this._elapsed = 0
|
||||||
this._attractionEnabledAt = 0
|
this._attractionEnabledAt = 0
|
||||||
this._attractionWasEnabled = false
|
this._attractionWasEnabled = false
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<template>
|
||||||
|
<div class="image-card">
|
||||||
|
<div class="imgrow">
|
||||||
|
<button class="img-thumb" title="选择本地图片" @click="$emit('pick', resource.id)">
|
||||||
|
<img v-if="resource.previewUrl" :src="resource.previewUrl" :alt="resource.textureName" />
|
||||||
|
<svg v-else viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="#8a93bb" stroke-width="1.6"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.6"/><path d="M21 15l-5-5-9 9"/></svg>
|
||||||
|
</button>
|
||||||
|
<div class="img-fields">
|
||||||
|
<label v-if="showFolder" class="row"><span>子文件夹</span><input v-model="resource.textureFolder" type="text" class="inp" placeholder="subfolder/" /></label>
|
||||||
|
<label class="row"><span>名称</span><input v-model="resource.textureName" type="text" class="inp" :placeholder="namePlaceholder" /></label>
|
||||||
|
</div>
|
||||||
|
<div class="img-actions">
|
||||||
|
<button class="mini-btn2" :title="`恢复 ${defaultFile}`" @click="$emit('reset', resource.id)">⟳</button>
|
||||||
|
<button class="mini-btn2" title="删除图片资源" :disabled="resourceCount === 1" @click="$emit('remove', resource.id)">🗑</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="thumb-note">{{ displayFileName }}</div>
|
||||||
|
<div class="resource-weight-row">
|
||||||
|
<NumSlider
|
||||||
|
class="resource-weight-control"
|
||||||
|
label="占比%"
|
||||||
|
:min="0"
|
||||||
|
:max="100"
|
||||||
|
:step="1"
|
||||||
|
:model-value="resource.weight"
|
||||||
|
:disabled="resource.locked || resourceCount === 1"
|
||||||
|
@update:model-value="$emit('updateWeight', resource.id, $event)"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="weight-lock"
|
||||||
|
:class="{ on: resource.locked }"
|
||||||
|
:disabled="resourceCount === 1"
|
||||||
|
:title="resource.locked ? '解除占比锁定' : '锁定当前占比'"
|
||||||
|
@click="$emit('toggleLock', resource.id)"
|
||||||
|
>
|
||||||
|
<svg v-if="resource.locked" viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="10" width="14" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg>
|
||||||
|
<svg v-else viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="10" width="14" height="10" rx="2"/><path d="M16 10V7a4 4 0 0 0-7.5-2"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import NumSlider from './NumSlider.vue'
|
||||||
|
|
||||||
|
type CommonImageResource = {
|
||||||
|
id: number
|
||||||
|
previewUrl: string
|
||||||
|
textureName: string
|
||||||
|
textureFolder?: string
|
||||||
|
weight: number
|
||||||
|
locked: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
resource: CommonImageResource
|
||||||
|
resourceCount: number
|
||||||
|
defaultFile: string
|
||||||
|
namePlaceholder?: string
|
||||||
|
showFolder?: boolean
|
||||||
|
}>(), {
|
||||||
|
namePlaceholder: 'particle',
|
||||||
|
showFolder: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
pick: [id: number]
|
||||||
|
reset: [id: number]
|
||||||
|
remove: [id: number]
|
||||||
|
updateWeight: [id: number, value: number]
|
||||||
|
toggleLock: [id: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const displayFileName = computed(() => {
|
||||||
|
const name = props.resource.textureName || props.defaultFile.replace(/\.[^.]+$/, '')
|
||||||
|
return name.includes('.') ? name : `${name}.png`
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.image-card { margin: 8px 0; padding: 9px; border: 1px solid #2e3c55; border-radius: 7px; background: #171d2a; }
|
||||||
|
.imgrow { display: flex; align-items: flex-start; gap: 8px; }
|
||||||
|
.img-thumb { width: 48px; height: 48px; padding: 0; border: 1px solid #3a4963; border-radius: 6px; background: #1a2232; display: flex; align-items: center; justify-content: center; flex-shrink: 0; cursor: pointer; overflow: hidden; }
|
||||||
|
.img-thumb:hover { border-color: #7774ff; }
|
||||||
|
.img-thumb img { display: block; width: 100%; height: 100%; object-fit: contain; }
|
||||||
|
.img-fields { flex: 1; min-width: 0; }
|
||||||
|
.img-actions { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.row { display: flex; align-items: center; gap: 8px; margin: 6px 0; color: #aab; font-size: 12px; }
|
||||||
|
.row > span:first-child { width: 60px; flex-shrink: 0; color: #8a93bb; }
|
||||||
|
.inp { min-width: 0; flex: 1; padding: 3px 5px; border: 1px solid #2e2e44; border-radius: 5px; background: #1a1a28; color: #dde; font-size: 12px; }
|
||||||
|
.mini-btn2 { width: 26px; height: 24px; background: #1e1e2e; border: 1px solid #2e2e44; border-radius: 5px; color: #aab; font-size: 12px; cursor: pointer; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.mini-btn2:hover { background: #2b3a5c; }
|
||||||
|
.mini-btn2:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||||
|
.thumb-note { margin: 4px 0 2px; color: #667; font-size: 11px; }
|
||||||
|
.resource-weight-row { display: grid; grid-template-columns: minmax(0, 1fr) 28px; align-items: center; gap: 7px; }
|
||||||
|
.resource-weight-control { width: 100%; min-width: 0; }
|
||||||
|
.resource-weight-control :deep(.ns-range) { min-width: 0; }
|
||||||
|
.resource-weight-control :deep(.ns-num) { flex: 0 0 56px; box-sizing: border-box; }
|
||||||
|
.weight-lock { width: 28px; height: 26px; flex: 0 0 28px; padding: 5px; border: 1px solid #35435c; border-radius: 5px; background: #202a3b; color: #78859e; cursor: pointer; }
|
||||||
|
.weight-lock svg { display: block; width: 100%; height: 100%; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
||||||
|
.weight-lock:hover:not(:disabled) { border-color: #7774ff; color: #b8b6ff; }
|
||||||
|
.weight-lock.on { border-color: #7774ff; background: #514fd0; color: #fff; }
|
||||||
|
.weight-lock:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||||
|
</style>
|
||||||
+293
-40
@@ -279,44 +279,18 @@
|
|||||||
<span>图片资源</span>
|
<span>图片资源</span>
|
||||||
<button class="image-add" title="新增图片资源" @click="addImageResource">+</button>
|
<button class="image-add" title="新增图片资源" @click="addImageResource">+</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-for="resource in sys.config.imageResources" :key="resource.id" class="image-card">
|
<ImageResourceCard
|
||||||
<div class="imgrow">
|
v-for="resource in sys.config.imageResources"
|
||||||
<button class="img-thumb" title="选择本地图片" @click="openTexturePicker(resource.id)">
|
:key="resource.id"
|
||||||
<img v-if="resource.previewUrl" :src="resource.previewUrl" :alt="resource.textureName" />
|
:resource="resource"
|
||||||
<svg v-else viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="#8a93bb" stroke-width="1.6"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.6"/><path d="M21 15l-5-5-9 9"/></svg>
|
:resource-count="sys.config.imageResources.length"
|
||||||
</button>
|
default-file="star.png"
|
||||||
<div class="img-fields">
|
@pick="openTexturePicker"
|
||||||
<label class="row"><span>子文件夹</span><input type="text" v-model="resource.textureFolder" class="inp" placeholder="subfolder/" /></label>
|
@reset="resetTexture"
|
||||||
<label class="row"><span>名称</span><input type="text" v-model="resource.textureName" class="inp" placeholder="particle" /></label>
|
@remove="removeImageResource"
|
||||||
</div>
|
@update-weight="setResourceWeight"
|
||||||
<div class="img-actions">
|
@toggle-lock="toggleResourceLock"
|
||||||
<button class="mini-btn2" title="恢复 star.png" @click="resetTexture(resource.id)">⟳</button>
|
>
|
||||||
<button class="mini-btn2" title="删除图片资源" :disabled="sys.config.imageResources.length === 1" @click="removeImageResource(resource.id)">🗑</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="thumb-note">{{ resource.textureName || 'star' }}{{ resource.textureName.includes('.') ? '' : '.png' }}</div>
|
|
||||||
<div class="resource-weight-row">
|
|
||||||
<NumSlider
|
|
||||||
class="resource-weight-control"
|
|
||||||
label="占比%"
|
|
||||||
:min="0"
|
|
||||||
:max="100"
|
|
||||||
:step="1"
|
|
||||||
:model-value="resource.weight"
|
|
||||||
:disabled="resource.locked || sys.config.imageResources.length === 1"
|
|
||||||
@update:model-value="setResourceWeight(resource.id, $event)"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
class="weight-lock"
|
|
||||||
:class="{ on: resource.locked }"
|
|
||||||
:disabled="sys.config.imageResources.length === 1"
|
|
||||||
:title="resource.locked ? '解除占比锁定' : '锁定当前占比'"
|
|
||||||
@click="toggleResourceLock(resource.id)"
|
|
||||||
>
|
|
||||||
<svg v-if="resource.locked" viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="10" width="14" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg>
|
|
||||||
<svg v-else viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="10" width="14" height="10" rx="2"/><path d="M16 10V7a4 4 0 0 0-7.5-2"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<NumSlider label="缩放" :min="0.1" :max="3" :step="0.05" v-model="resource.scale" />
|
<NumSlider label="缩放" :min="0.1" :max="3" :step="0.05" v-model="resource.scale" />
|
||||||
<label class="row"><span>轴心 x,y</span><input type="number" step="0.05" class="inp tiny" v-model.number="resource.anchorX" /><input type="number" step="0.05" class="inp tiny" v-model.number="resource.anchorY" /></label>
|
<label class="row"><span>轴心 x,y</span><input type="number" step="0.05" class="inp tiny" v-model.number="resource.anchorX" /><input type="number" step="0.05" class="inp tiny" v-model.number="resource.anchorY" /></label>
|
||||||
|
|
||||||
@@ -359,7 +333,7 @@
|
|||||||
<CurveEditor v-model="resource.alphaCurve" :default-value="RESOURCE_ALPHA_CURVE" />
|
<CurveEditor v-model="resource.alphaCurve" :default-value="RESOURCE_ALPHA_CURVE" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</ImageResourceCard>
|
||||||
<input ref="textureFileInput" class="file-input" type="file" accept="image/png,image/jpeg,image/webp,image/gif" @change="onTextureFile" />
|
<input ref="textureFileInput" class="file-input" type="file" accept="image/png,image/jpeg,image/webp,image/gif" @change="onTextureFile" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -472,7 +446,94 @@
|
|||||||
<div class="modifier-hint">延迟后从当前位置开始吸附 · 到达红色范围即死亡 · 可在画布内拖动吸附圆</div>
|
<div class="modifier-hint">延迟后从当前位置开始吸附 · 到达红色范围即死亡 · 可在画布内拖动吸附圆</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label class="row"><span>拖尾</span><input type="checkbox" v-model="sys.config.trail" /></label>
|
<div class="modifier-item">
|
||||||
|
<label class="switch-row modifier-switch">
|
||||||
|
<input type="checkbox" v-model="sys.config.trail" />
|
||||||
|
<span class="switch-ui"></span><span>开启拖尾 (Trail)</span>
|
||||||
|
</label>
|
||||||
|
<div v-if="sys.config.trail" class="modifier-params trail-params">
|
||||||
|
<div class="trail-section-head">
|
||||||
|
<div class="trail-section-title">拖尾独立图片</div>
|
||||||
|
<button class="image-add" title="增加拖尾图片资源" @click="addTrailResource">+</button>
|
||||||
|
</div>
|
||||||
|
<ImageResourceCard
|
||||||
|
v-for="resource in sys.config.trailResources"
|
||||||
|
:key="resource.id"
|
||||||
|
:resource="resource"
|
||||||
|
:resource-count="sys.config.trailResources.length"
|
||||||
|
default-file="trail.png"
|
||||||
|
name-placeholder="trail"
|
||||||
|
:show-folder="false"
|
||||||
|
@pick="openTrailTexturePicker"
|
||||||
|
@reset="resetTrailTexture"
|
||||||
|
@remove="removeTrailResource"
|
||||||
|
@update-weight="setTrailResourceWeight"
|
||||||
|
@toggle-lock="toggleTrailResourceLock"
|
||||||
|
>
|
||||||
|
<div class="resource-divider"></div>
|
||||||
|
<NumSlider label="旋转" :min="-360" :max="360" :step="1" :model-value="resource.rotation" @update:model-value="onTrailResourceRotationChange(resource.id, $event)" />
|
||||||
|
<NumSlider label="拖尾骨骼数" :min="2" :max="16" :step="1" :model-value="resource.boneCount" @update:model-value="onTrailResourceBoneCountChange(resource.id, $event)" />
|
||||||
|
<NumSlider label="初始长度" :min="0" :max="1000" :step="1" v-model="resource.length" />
|
||||||
|
<NumSlider label="初始宽度" :min="0" :max="1000" :step="1" v-model="resource.width" />
|
||||||
|
<label class="row"><span>宽度缩放</span>
|
||||||
|
<select v-model="resource.widthMode" class="inp">
|
||||||
|
<option value="particle">跟随粒子</option>
|
||||||
|
<option value="fixed">固定</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="switch-row trail-sub-switch">
|
||||||
|
<input type="checkbox" v-model="resource.lifeLengthEnabled" />
|
||||||
|
<span class="switch-ui"></span><span>生命周期长度曲线</span>
|
||||||
|
</label>
|
||||||
|
<CurveEditor v-if="resource.lifeLengthEnabled" v-model="resource.lifeLengthCurve" :default-value="FLAT_CURVE" />
|
||||||
|
|
||||||
|
<label class="switch-row trail-sub-switch">
|
||||||
|
<input type="checkbox" v-model="resource.shapeEnabled" />
|
||||||
|
<span class="switch-ui"></span><span>形状曲线(首端 → 尾端宽度)</span>
|
||||||
|
</label>
|
||||||
|
<CurveEditor v-if="resource.shapeEnabled" v-model="resource.shapeCurve" :default-value="TRAIL_SHAPE_CURVE" />
|
||||||
|
|
||||||
|
<div class="trail-mode-head"><span>颜色模式</span>
|
||||||
|
<div class="alpha-modes">
|
||||||
|
<button class="axis-mode" :class="{ on: resource.colorMode === 'particle' }" @click="resource.colorMode = 'particle'">跟随粒子</button>
|
||||||
|
<button class="axis-mode" :class="{ on: resource.colorMode === 'fixed' }" @click="resource.colorMode = 'fixed'">固定</button>
|
||||||
|
<button class="axis-mode" :class="{ on: resource.colorMode === 'curve' }" @click="resource.colorMode = 'curve'">曲线</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="row"><span>混合模式</span>
|
||||||
|
<select v-model="resource.blend" class="inp">
|
||||||
|
<option value="normal">正常</option>
|
||||||
|
<option value="add">相加</option>
|
||||||
|
<option value="multiply">相乘</option>
|
||||||
|
<option value="screen">滤色</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label v-if="resource.colorMode === 'fixed'" class="row trail-fixed-color"><span>固定颜色</span>
|
||||||
|
<input class="colr" type="color" v-model="resource.color" />
|
||||||
|
<input class="hex-input" :value="resource.color" @change="setTrailColor(resource, $event)" @keydown.enter.prevent="commitTrailColor(resource, $event)" />
|
||||||
|
</label>
|
||||||
|
<ColorGradientEditor v-else-if="resource.colorMode === 'curve'" v-model="resource.colorGradient" />
|
||||||
|
|
||||||
|
<div class="trail-mode-head"><span>透明度模式</span>
|
||||||
|
<div class="alpha-modes">
|
||||||
|
<button class="axis-mode" :class="{ on: resource.alphaMode === 'particle' }" @click="resource.alphaMode = 'particle'">跟随粒子</button>
|
||||||
|
<button class="axis-mode" :class="{ on: resource.alphaMode === 'fixed' }" @click="resource.alphaMode = 'fixed'">固定</button>
|
||||||
|
<button class="axis-mode" :class="{ on: resource.alphaMode === 'curve' }" @click="resource.alphaMode = 'curve'">曲线</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<NumSlider v-if="resource.alphaMode === 'fixed'" label="透明度" :min="0" :max="1" :step="0.01" v-model="resource.alpha" />
|
||||||
|
<CurveEditor v-if="resource.alphaMode === 'curve'" v-model="resource.alphaCurve" :default-value="TRAIL_ALPHA_CURVE" />
|
||||||
|
</ImageResourceCard>
|
||||||
|
<input ref="trailTextureFileInput" class="file-input" type="file" accept="image/png,image/jpeg,image/webp" @change="onTrailTextureFile" />
|
||||||
|
|
||||||
|
<div class="trail-grid-row">
|
||||||
|
<NumSlider label="网格行数" :min="2" :max="16" :step="1" :model-value="sys.config.trailGridRows" @update:model-value="onTrailRigChange('trailGridRows', $event)" />
|
||||||
|
<NumSlider label="网格列数" :min="2" :max="32" :step="1" :model-value="sys.config.trailGridCols" @update:model-value="onTrailRigChange('trailGridCols', $event)" />
|
||||||
|
</div>
|
||||||
|
<div class="modifier-hint">每个粒子生成独立拖尾骨骼链;网格按行列自动重建,并由相邻骨骼权重共同驱动蒙皮变形</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<label class="row"><span>碰撞</span><input type="checkbox" v-model="sys.config.collision" /><span class="tag">预留</span></label>
|
<label class="row"><span>碰撞</span><input type="checkbox" v-model="sys.config.collision" /><span class="tag">预留</span></label>
|
||||||
<label class="row"><span>路径跟随</span><input type="checkbox" v-model="sys.config.pathFollow" /><span class="tag">预留</span></label>
|
<label class="row"><span>路径跟随</span><input type="checkbox" v-model="sys.config.pathFollow" /><span class="tag">预留</span></label>
|
||||||
<NumSlider label="阻尼" :min="0" :max="2" :step="0.05" v-model="sys.config.damping" />
|
<NumSlider label="阻尼" :min="0" :max="2" :step="0.05" v-model="sys.config.damping" />
|
||||||
@@ -502,6 +563,7 @@ import NumSlider from './NumSlider.vue'
|
|||||||
import CurveEditor from './CurveEditor.vue'
|
import CurveEditor from './CurveEditor.vue'
|
||||||
import ParticleAttributeControl from './ParticleAttributeControl.vue'
|
import ParticleAttributeControl from './ParticleAttributeControl.vue'
|
||||||
import ColorGradientEditor from './ColorGradientEditor.vue'
|
import ColorGradientEditor from './ColorGradientEditor.vue'
|
||||||
|
import ImageResourceCard from './ImageResourceCard.vue'
|
||||||
|
|
||||||
const store = useParticleStore()
|
const store = useParticleStore()
|
||||||
// 默认场景始终有一个可编辑的粒子系统;热更新保留现有系统时不重复创建。
|
// 默认场景始终有一个可编辑的粒子系统;热更新保留现有系统时不重复创建。
|
||||||
@@ -528,6 +590,8 @@ const LINEAR_CURVE = [{ x: 0, y: 0 }, { x: 1, y: 1 }]
|
|||||||
const FLAT_CURVE = [{ x: 0, y: 1 }, { 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 ALPHA_CURVE = [{ x: 0, y: 0 }, { x: 0.2, y: 1 }, { x: 0.8, y: 1 }, { x: 1, y: 0 }]
|
||||||
const RESOURCE_ALPHA_CURVE = [{ x: 0, y: 0 }, { x: 0.1, y: 1 }, { x: 0.9, y: 1 }, { x: 1, y: 0 }]
|
const RESOURCE_ALPHA_CURVE = [{ x: 0, y: 0 }, { x: 0.1, y: 1 }, { x: 0.9, y: 1 }, { x: 1, y: 0 }]
|
||||||
|
const TRAIL_ALPHA_CURVE = [{ x: 0, y: 1 }, { x: 1, y: 1 }]
|
||||||
|
const TRAIL_SHAPE_CURVE = [{ x: 0, y: 1 }, { x: 1, y: 1 }]
|
||||||
|
|
||||||
const collapsed = reactive<Record<string, boolean>>({
|
const collapsed = reactive<Record<string, boolean>>({
|
||||||
scene: false,
|
scene: false,
|
||||||
@@ -595,6 +659,176 @@ function onShapeChange() {
|
|||||||
// 重置/删除图片 → 回到默认 star.png
|
// 重置/删除图片 → 回到默认 star.png
|
||||||
const textureFileInput = ref<HTMLInputElement | null>(null)
|
const textureFileInput = ref<HTMLInputElement | null>(null)
|
||||||
const pendingResourceId = ref<number | null>(null)
|
const pendingResourceId = ref<number | null>(null)
|
||||||
|
const trailTextureFileInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const pendingTrailResourceId = ref<number | null>(null)
|
||||||
|
|
||||||
|
function addTrailResource() {
|
||||||
|
const config = sys.value?.config
|
||||||
|
if (!config) return
|
||||||
|
const nextId = Math.max(0, ...config.trailResources.map((resource) => resource.id)) + 1
|
||||||
|
config.trailResources.push({
|
||||||
|
id: nextId,
|
||||||
|
texture: null,
|
||||||
|
textureName: 'trail',
|
||||||
|
previewUrl: '/trails/trail.png',
|
||||||
|
weight: 100,
|
||||||
|
locked: false,
|
||||||
|
rotation: 0,
|
||||||
|
colorMode: 'particle',
|
||||||
|
color: '#ffffff',
|
||||||
|
colorGradient: [{ position: 0, color: '#ffffff' }, { position: 1, color: '#ffffff' }],
|
||||||
|
blend: 'normal',
|
||||||
|
alphaMode: 'particle',
|
||||||
|
alpha: 1,
|
||||||
|
alphaCurve: TRAIL_ALPHA_CURVE.map((point) => ({ ...point })),
|
||||||
|
boneCount: 3,
|
||||||
|
length: 100,
|
||||||
|
width: 100,
|
||||||
|
widthMode: 'particle',
|
||||||
|
lifeLengthEnabled: false,
|
||||||
|
lifeLengthCurve: FLAT_CURVE.map((point) => ({ ...point })),
|
||||||
|
shapeEnabled: false,
|
||||||
|
shapeCurve: TRAIL_SHAPE_CURVE.map((point) => ({ ...point })),
|
||||||
|
})
|
||||||
|
rebalanceTrailWeights()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTrailTexturePicker(resourceId: number) {
|
||||||
|
pendingTrailResourceId.value = resourceId
|
||||||
|
trailTextureFileInput.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrailRigKey = 'trailGridRows' | 'trailGridCols'
|
||||||
|
function onTrailRigChange(key: TrailRigKey, value: number) {
|
||||||
|
const config = sys.value?.config
|
||||||
|
if (!config) return
|
||||||
|
const maximum = key === 'trailGridCols' ? 32 : 16
|
||||||
|
config[key] = Math.min(maximum, Math.max(2, Math.round(Number(value) || 2)))
|
||||||
|
// 只触发骨骼和蒙皮重建;所有正在使用的拖尾图片与粒子资源选择保持不变。
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTrailResourceBoneCountChange(resourceId: number, value: number) {
|
||||||
|
const resource = sys.value?.config.trailResources.find((item) => item.id === resourceId)
|
||||||
|
if (!resource) return
|
||||||
|
resource.boneCount = Math.min(16, Math.max(2, Math.round(Number(value) || 2)))
|
||||||
|
// 骨骼数量包含在该资源的蒙皮签名中,运行层会保留图片并重新绑定。
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTrailResourceRotationChange(resourceId: number, value: number) {
|
||||||
|
const resource = sys.value?.config.trailResources.find((item) => item.id === resourceId)
|
||||||
|
if (!resource) return
|
||||||
|
resource.rotation = Math.max(-360, Math.min(360, Number(value) || 0))
|
||||||
|
// 旋转属于图片默认方向;运行层将新的角度写入 UV 签名并重新绑定蒙皮。
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onTrailTextureFile(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
const file = input.files?.[0]
|
||||||
|
const config = sys.value?.config
|
||||||
|
const resource = config?.trailResources.find((item) => item.id === pendingTrailResourceId.value)
|
||||||
|
if (!file || !resource) { input.value = ''; return }
|
||||||
|
const dataUrl = await readFileAsDataUrl(file)
|
||||||
|
resource.texture = markRaw(await Texture.fromURL(dataUrl)) as any
|
||||||
|
resource.previewUrl = dataUrl
|
||||||
|
resource.textureName = file.name.replace(/\.[^.]+$/, '') || 'trail'
|
||||||
|
input.value = ''
|
||||||
|
pendingTrailResourceId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetTrailTexture(resourceId: number) {
|
||||||
|
const config = sys.value?.config
|
||||||
|
const resource = config?.trailResources.find((item) => item.id === resourceId)
|
||||||
|
if (!resource) return
|
||||||
|
resource.texture = markRaw(await Texture.fromURL('/trails/trail.png')) as any
|
||||||
|
resource.previewUrl = '/trails/trail.png'
|
||||||
|
resource.textureName = 'trail'
|
||||||
|
resource.weight = 100
|
||||||
|
resource.locked = false
|
||||||
|
resource.rotation = 0
|
||||||
|
resource.boneCount = 3
|
||||||
|
resource.length = 100
|
||||||
|
resource.width = 100
|
||||||
|
resource.widthMode = 'particle'
|
||||||
|
resource.lifeLengthEnabled = false
|
||||||
|
resource.lifeLengthCurve = FLAT_CURVE.map((point) => ({ ...point }))
|
||||||
|
resource.shapeEnabled = false
|
||||||
|
resource.shapeCurve = TRAIL_SHAPE_CURVE.map((point) => ({ ...point }))
|
||||||
|
resource.colorMode = 'particle'
|
||||||
|
resource.color = '#ffffff'
|
||||||
|
resource.colorGradient = [
|
||||||
|
{ position: 0, color: '#ffffff' },
|
||||||
|
{ position: 1, color: '#ffffff' },
|
||||||
|
]
|
||||||
|
resource.blend = 'normal'
|
||||||
|
resource.alphaMode = 'particle'
|
||||||
|
resource.alpha = 1
|
||||||
|
resource.alphaCurve = TRAIL_ALPHA_CURVE.map((point) => ({ ...point }))
|
||||||
|
rebalanceTrailWeights()
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTrailResource(resourceId: number) {
|
||||||
|
const resources = sys.value?.config.trailResources
|
||||||
|
if (!resources || resources.length <= 1) return
|
||||||
|
const index = resources.findIndex((resource) => resource.id === resourceId)
|
||||||
|
if (index >= 0) resources.splice(index, 1)
|
||||||
|
rebalanceTrailWeights()
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebalanceTrailWeights() {
|
||||||
|
const resources = sys.value?.config.trailResources
|
||||||
|
if (!resources?.length) return
|
||||||
|
if (resources.length === 1) {
|
||||||
|
resources[0].weight = 100
|
||||||
|
resources[0].locked = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const locked = resources.filter((resource) => resource.locked)
|
||||||
|
const unlocked = resources.filter((resource) => !resource.locked)
|
||||||
|
let lockedTotal = locked.reduce((sum, resource) => sum + Math.max(0, Number(resource.weight) || 0), 0)
|
||||||
|
if (lockedTotal > 100) {
|
||||||
|
distributeImageWeights(locked, 100, true)
|
||||||
|
lockedTotal = 100
|
||||||
|
}
|
||||||
|
distributeImageWeights(unlocked, 100 - lockedTotal)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTrailResourceWeight(resourceId: number, value: number) {
|
||||||
|
const resources = sys.value?.config.trailResources
|
||||||
|
if (!resources?.length) return
|
||||||
|
if (resources.length === 1) {
|
||||||
|
resources[0].weight = 100
|
||||||
|
resources[0].locked = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const resource = resources.find((item) => item.id === resourceId)
|
||||||
|
if (!resource || resource.locked) return
|
||||||
|
const lockedTotal = resources
|
||||||
|
.filter((item) => item.id !== resourceId && item.locked)
|
||||||
|
.reduce((sum, item) => sum + Math.max(0, Number(item.weight) || 0), 0)
|
||||||
|
const available = Math.max(0, 100 - lockedTotal)
|
||||||
|
const others = resources.filter((item) => item.id !== resourceId && !item.locked)
|
||||||
|
resource.weight = roundWeight(Math.min(available, Math.max(0, Number(value) || 0)))
|
||||||
|
if (!others.length) resource.weight = roundWeight(available)
|
||||||
|
else distributeImageWeights(others, available - resource.weight, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTrailResourceLock(resourceId: number) {
|
||||||
|
const resources = sys.value?.config.trailResources
|
||||||
|
if (!resources || resources.length === 1) return
|
||||||
|
const resource = resources.find((item) => item.id === resourceId)
|
||||||
|
if (resource) resource.locked = !resource.locked
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTrailColor(resource: { color: string }, event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
resource.color = normalizeHex(input.value, resource.color)
|
||||||
|
input.value = resource.color
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitTrailColor(resource: { color: string }, event: KeyboardEvent) {
|
||||||
|
setTrailColor(resource, event)
|
||||||
|
;(event.target as HTMLInputElement).blur()
|
||||||
|
}
|
||||||
|
|
||||||
function addImageResource() {
|
function addImageResource() {
|
||||||
const config = sys.value?.config
|
const config = sys.value?.config
|
||||||
@@ -898,4 +1132,23 @@ function toggleResourceLock(resourceId: number) {
|
|||||||
.gravity-curve-title span:last-child { color: #657188; font-size: 9px; }
|
.gravity-curve-title span:last-child { color: #657188; font-size: 9px; }
|
||||||
.gravity-curve-axis { display: flex; justify-content: space-between; margin: 3px 34px 0 1px; color: #5f6b82; font-size: 9px; }
|
.gravity-curve-axis { display: flex; justify-content: space-between; margin: 3px 34px 0 1px; color: #5f6b82; font-size: 9px; }
|
||||||
.gravity-params :deep(.ce-svg) { height: 96px; background: #0f1625; border-color: #30405a; }
|
.gravity-params :deep(.ce-svg) { height: 96px; background: #0f1625; border-color: #30405a; }
|
||||||
|
.trail-params { padding-right: 2px; }
|
||||||
|
.trail-section-title { margin: 3px 0 8px; color: #aeb8cf; font-size: 12px; font-weight: 700; }
|
||||||
|
.trail-resource-row { display: flex; align-items: stretch; gap: 8px; margin-bottom: 10px; padding: 8px; border: 1px solid #2e3c55; border-radius: 7px; background: #171d2a; }
|
||||||
|
.trail-thumb { width: 58px; min-height: 58px; padding: 3px; flex: 0 0 58px; border: 1px solid #3a4963; border-radius: 6px; background: #101725; cursor: pointer; overflow: hidden; }
|
||||||
|
.trail-thumb:hover { border-color: #7774ff; }
|
||||||
|
.trail-thumb img { display: block; width: 100%; height: 100%; object-fit: contain; }
|
||||||
|
.trail-resource-fields { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 7px; }
|
||||||
|
.trail-color-row { display: flex; align-items: center; gap: 5px; color: #8a93bb; font-size: 11px; }
|
||||||
|
.trail-color-row > span { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.trail-color-row .hex-input { min-width: 0; flex: 1; }
|
||||||
|
.trail-fixed-color .hex-input { min-width: 0; flex: 1; }
|
||||||
|
.trail-mode-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 10px 0 6px; color: #9ba7bd; font-size: 11px; }
|
||||||
|
.trail-sub-switch { margin: 11px 0 7px; padding-top: 9px; border-top: 1px solid #2a3549; }
|
||||||
|
.trail-grid-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 8px; }
|
||||||
|
.trail-grid-row :deep(.ns) { min-width: 0; flex-wrap: wrap; align-items: center; }
|
||||||
|
.trail-grid-row :deep(.ns-label) { width: 100%; }
|
||||||
|
.trail-grid-row :deep(.ns-range) { min-width: 0; }
|
||||||
|
.trail-grid-row :deep(.ns-num) { width: 42px; }
|
||||||
|
.trail-params :deep(.ce-svg) { height: 92px; background: #0f1625; border-color: #30405a; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+44
-13
@@ -7,6 +7,7 @@
|
|||||||
<label class="brand">粒子发射编辑器</label>
|
<label class="brand">粒子发射编辑器</label>
|
||||||
<label class="chk"><input type="checkbox" v-model="showEmitter" />发射点</label>
|
<label class="chk"><input type="checkbox" v-model="showEmitter" />发射点</label>
|
||||||
<label class="chk"><input type="checkbox" v-model="showBones" />骨骼连线</label>
|
<label class="chk"><input type="checkbox" v-model="showBones" />骨骼连线</label>
|
||||||
|
<label class="chk"><input type="checkbox" v-model="showSkinMesh" />蒙皮网格</label>
|
||||||
|
|
||||||
<!-- 根变换工具:拖动(默认) / 位移 / 旋转 / 缩放 -->
|
<!-- 根变换工具:拖动(默认) / 位移 / 旋转 / 缩放 -->
|
||||||
<div class="tool-group">
|
<div class="tool-group">
|
||||||
@@ -63,7 +64,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { Application, Container, Graphics, Text, Texture } from 'pixi.js'
|
import { Application, Container, Graphics, Text, Texture } from 'pixi.js'
|
||||||
import { ParticleEmitter, ensureEmitterConfig } from '../core/particleEmitter'
|
import { ParticleEmitter, ensureEmitterConfig, type ParticleState } from '../core/particleEmitter'
|
||||||
import { useParticleStore } from '../store/particleStore'
|
import { useParticleStore } from '../store/particleStore'
|
||||||
|
|
||||||
const store = useParticleStore()
|
const store = useParticleStore()
|
||||||
@@ -71,6 +72,7 @@ const holder = ref<HTMLElement | null>(null)
|
|||||||
const time = ref(0)
|
const time = ref(0)
|
||||||
const showEmitter = ref(true)
|
const showEmitter = ref(true)
|
||||||
const showBones = ref(false)
|
const showBones = ref(false)
|
||||||
|
const showSkinMesh = ref(false)
|
||||||
const info = ref<{ systems: number; particles: number } | null>(null)
|
const info = ref<{ systems: number; particles: number } | null>(null)
|
||||||
const showGrid = ref(true)
|
const showGrid = ref(true)
|
||||||
const showSettings = ref(false)
|
const showSettings = ref(false)
|
||||||
@@ -92,12 +94,18 @@ let axisLabels: Text[] = []
|
|||||||
let lastT = 0
|
let lastT = 0
|
||||||
let raf = 0
|
let raf = 0
|
||||||
let dotTex: Texture<any> | null = null
|
let dotTex: Texture<any> | null = null
|
||||||
|
let trailTex: Texture<any> | null = null
|
||||||
|
|
||||||
const emitterMap = new Map<number, ParticleEmitter>()
|
const emitterMap = new Map<number, ParticleEmitter>()
|
||||||
|
|
||||||
async function loadTexture() {
|
async function loadTexture() {
|
||||||
if (dotTex) return dotTex
|
if (dotTex && trailTex) return dotTex
|
||||||
dotTex = await Texture.fromURL('/particles/star.png')
|
const [particleTexture, trailTexture] = await Promise.all([
|
||||||
|
Texture.fromURL('/particles/star.png'),
|
||||||
|
Texture.fromURL('/trails/trail.png'),
|
||||||
|
])
|
||||||
|
dotTex = particleTexture
|
||||||
|
trailTex = trailTexture
|
||||||
return dotTex
|
return dotTex
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,6 +403,11 @@ function syncEmitters() {
|
|||||||
// 新增系统若还没贴图(dotTex 已加载后),补上默认点纹理,否则无法渲染
|
// 新增系统若还没贴图(dotTex 已加载后),补上默认点纹理,否则无法渲染
|
||||||
if (dotTex && !sys.config.texture) sys.config.texture = dotTex
|
if (dotTex && !sys.config.texture) sys.config.texture = dotTex
|
||||||
const cfg = ensureEmitterConfig(sys.config as any)
|
const cfg = ensureEmitterConfig(sys.config as any)
|
||||||
|
if (trailTex) {
|
||||||
|
for (const resource of cfg.trailResources) {
|
||||||
|
if (!resource.texture && resource.previewUrl === '/trails/trail.png') resource.texture = trailTex
|
||||||
|
}
|
||||||
|
}
|
||||||
if (dotTex) {
|
if (dotTex) {
|
||||||
for (const resource of cfg.imageResources) {
|
for (const resource of cfg.imageResources) {
|
||||||
if (!resource.texture && resource.textureName === 'star') resource.texture = dotTex
|
if (!resource.texture && resource.textureName === 'star') resource.texture = dotTex
|
||||||
@@ -609,7 +622,18 @@ function drawGizmo() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 粒子→骨骼映射:每个活动粒子 = 一根挂在 root 下的骨骼
|
// 粒子→骨骼映射:每个活动粒子 = 一根挂在 root 下的骨骼
|
||||||
function drawBones(bones: { boneName: string; x: number; y: number }[]) {
|
type BonePreview = { boneName: string; x: number; y: number; parentX?: number; parentY?: number; trail?: boolean }
|
||||||
|
|
||||||
|
function appendStateBones(target: BonePreview[], state: ParticleState) {
|
||||||
|
target.push({ boneName: state.boneName, x: state.x, y: state.y })
|
||||||
|
if (!state.trailState?.bones.length) return
|
||||||
|
state.trailState.bones.forEach((bone, index, bones) => {
|
||||||
|
const parent = index > 0 ? bones[index - 1] : { x: state.x, y: state.y }
|
||||||
|
target.push({ boneName: bone.boneName, x: bone.x, y: bone.y, parentX: parent.x, parentY: parent.y, trail: true })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawBones(bones: BonePreview[]) {
|
||||||
if (!boneGfx) return
|
if (!boneGfx) return
|
||||||
const g = boneGfx
|
const g = boneGfx
|
||||||
g.clear()
|
g.clear()
|
||||||
@@ -617,11 +641,12 @@ function drawBones(bones: { boneName: string; x: number; y: number }[]) {
|
|||||||
const cx = 0, cy = 0
|
const cx = 0, cy = 0
|
||||||
// root → 每颗粒子骨骼 的连线
|
// root → 每颗粒子骨骼 的连线
|
||||||
for (const b of bones) {
|
for (const b of bones) {
|
||||||
g.lineStyle(1, 0xff8c5a, 0.35)
|
const color = b.trail ? 0xff4d8d : 0xff8c5a
|
||||||
g.moveTo(cx, cy)
|
g.lineStyle(1, color, b.trail ? 0.65 : 0.35)
|
||||||
|
g.moveTo(b.parentX ?? cx, b.parentY ?? cy)
|
||||||
g.lineTo(b.x, b.y)
|
g.lineTo(b.x, b.y)
|
||||||
// 骨骼点
|
// 骨骼点
|
||||||
g.beginFill(0xff8c5a, 0.9)
|
g.beginFill(color, 0.9)
|
||||||
g.drawCircle(b.x, b.y, 2.5)
|
g.drawCircle(b.x, b.y, 2.5)
|
||||||
g.endFill()
|
g.endFill()
|
||||||
}
|
}
|
||||||
@@ -650,7 +675,7 @@ function loop() {
|
|||||||
em.scale.set(c.rootScaleX, c.rootScaleY)
|
em.scale.set(c.rootScaleX, c.rootScaleY)
|
||||||
}
|
}
|
||||||
let total = 0
|
let total = 0
|
||||||
const collected: { boneName: string; x: number; y: number }[] = []
|
const collected: BonePreview[] = []
|
||||||
const tl = store.timeline
|
const tl = store.timeline
|
||||||
const fps = tl.fps
|
const fps = tl.fps
|
||||||
const dt = 1 / fps // 固定帧步长,保证帧对齐
|
const dt = 1 / fps // 固定帧步长,保证帧对齐
|
||||||
@@ -660,7 +685,7 @@ function loop() {
|
|||||||
if (tl.frame === 0) { for (const [, em] of emitterMap) em.reset() }
|
if (tl.frame === 0) { for (const [, em] of emitterMap) em.reset() }
|
||||||
for (const [, em] of emitterMap) {
|
for (const [, em] of emitterMap) {
|
||||||
const st = em.update(dt)
|
const st = em.update(dt)
|
||||||
for (const s of st) if (s.active) collected.push({ boneName: s.boneName, x: s.x, y: s.y })
|
for (const s of st) if (s.active) appendStateBones(collected, s)
|
||||||
total += em.activeCount
|
total += em.activeCount
|
||||||
}
|
}
|
||||||
for (const sys of store.systems) {
|
for (const sys of store.systems) {
|
||||||
@@ -676,7 +701,7 @@ function loop() {
|
|||||||
for (const sys of store.systems) {
|
for (const sys of store.systems) {
|
||||||
const em = emitterMap.get(sys.id)
|
const em = emitterMap.get(sys.id)
|
||||||
const fr = sys.frames?.[tl.frame]
|
const fr = sys.frames?.[tl.frame]
|
||||||
if (em && fr) { em.apply(fr); for (const s of fr) if (s.active) { collected.push({ boneName: s.boneName, x: s.x, y: s.y }); total++ } }
|
if (em && fr) { em.apply(fr); for (const s of fr) if (s.active) { appendStateBones(collected, s); total++ } }
|
||||||
}
|
}
|
||||||
tl.frame++
|
tl.frame++
|
||||||
if (tl.frame >= tl.totalFrames) { if (tl.loop) tl.frame = 0; else { tl.frame = tl.totalFrames - 1; tl.playing = false } }
|
if (tl.frame >= tl.totalFrames) { if (tl.loop) tl.frame = 0; else { tl.frame = tl.totalFrames - 1; tl.playing = false } }
|
||||||
@@ -688,14 +713,15 @@ function loop() {
|
|||||||
const fr = sys.frames?.[tl.frame]
|
const fr = sys.frames?.[tl.frame]
|
||||||
if (em && tl.recorded && fr) {
|
if (em && tl.recorded && fr) {
|
||||||
em.apply(fr)
|
em.apply(fr)
|
||||||
for (const s of fr) if (s.active) { collected.push({ boneName: s.boneName, x: s.x, y: s.y }); total++ }
|
for (const s of fr) if (s.active) { appendStateBones(collected, s); total++ }
|
||||||
} else if (em) {
|
} else if (em) {
|
||||||
const st = em.update(0) // 冻结预览
|
const st = em.update(0) // 冻结预览
|
||||||
for (const s of st) if (s.active) collected.push({ boneName: s.boneName, x: s.x, y: s.y })
|
for (const s of st) if (s.active) appendStateBones(collected, s)
|
||||||
total += em.activeCount
|
total += em.activeCount
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (const [, emitter] of emitterMap) emitter.drawTrailMeshDebug(showSkinMesh.value)
|
||||||
drawOrigin()
|
drawOrigin()
|
||||||
drawShapeRange()
|
drawShapeRange()
|
||||||
drawForceField()
|
drawForceField()
|
||||||
@@ -716,6 +742,11 @@ onMounted(async () => {
|
|||||||
for (const sys of store.systems) {
|
for (const sys of store.systems) {
|
||||||
sys.config.texture = tex
|
sys.config.texture = tex
|
||||||
const cfg = ensureEmitterConfig(sys.config as any)
|
const cfg = ensureEmitterConfig(sys.config as any)
|
||||||
|
if (trailTex) {
|
||||||
|
for (const resource of cfg.trailResources) {
|
||||||
|
if (!resource.texture && resource.previewUrl === '/trails/trail.png') resource.texture = trailTex
|
||||||
|
}
|
||||||
|
}
|
||||||
for (const resource of cfg.imageResources) {
|
for (const resource of cfg.imageResources) {
|
||||||
if (!resource.texture && resource.textureName === 'star') resource.texture = tex
|
if (!resource.texture && resource.textureName === 'star') resource.texture = tex
|
||||||
}
|
}
|
||||||
@@ -737,7 +768,7 @@ onMounted(async () => {
|
|||||||
watch(
|
watch(
|
||||||
() =>
|
() =>
|
||||||
store.systems
|
store.systems
|
||||||
.map((s) => JSON.stringify(s.config, (k, v) => (k === 'texture' || k === 'previewUrl' ? undefined : v)))
|
.map((s) => JSON.stringify(s.config, (k, v) => (k === 'texture' || k === 'trailTexture' || k === 'previewUrl' ? undefined : v)))
|
||||||
.join('|'),
|
.join('|'),
|
||||||
() => {
|
() => {
|
||||||
const frames = store.recalcTotalFrames()
|
const frames = store.recalcTotalFrames()
|
||||||
|
|||||||
+448
@@ -0,0 +1,448 @@
|
|||||||
|
# 网页粒子系统 · 阶段五
|
||||||
|
|
||||||
|
> 阶段定位:**粒子拖尾模块定版**
|
||||||
|
> 完成日期:2026-08-28
|
||||||
|
> 工程目录:`/Users/tianmokeji/Desktop/SpineParticle`
|
||||||
|
> 技术栈:Vue 3 + TypeScript + Pinia + PixiJS 7 + Vite
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、阶段结论
|
||||||
|
|
||||||
|
阶段五完成了粒子拖尾模块的第一版完整实现。拖尾不再是独立于粒子的静态图片,而是由每个粒子自己的历史轨迹、拖尾骨骼链和网格蒙皮共同驱动,并与粒子的最终渲染位置保持连接。
|
||||||
|
|
||||||
|
本阶段主要完成:
|
||||||
|
|
||||||
|
1. 默认 `trail.png` 拖尾资源;
|
||||||
|
2. 多拖尾图片资源和占比分配;
|
||||||
|
3. 每张拖尾资源独立的骨骼、尺寸、旋转、颜色、透明度和曲线配置;
|
||||||
|
4. 按网格行列自动生成蒙皮拓扑;
|
||||||
|
5. 按拖尾骨骼数生成独立骨骼链;
|
||||||
|
6. 使用相邻骨骼权重对图片网格进行 CPU 蒙皮;
|
||||||
|
7. 拖尾跟随粒子最终渲染位置,包含移动噪声等视觉位移;
|
||||||
|
8. 拖尾状态参与固定帧录制与回放;
|
||||||
|
9. 画布蒙皮网格调试显示;
|
||||||
|
10. 卡片全量重置和旧配置迁移。
|
||||||
|
|
||||||
|
拖尾模块默认关闭,不会改变未启用拖尾时的粒子表现。阶段三的外观资源和阶段四的修改器功能保持原有行为。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、拖尾默认状态
|
||||||
|
|
||||||
|
### 2.1 模块默认状态
|
||||||
|
|
||||||
|
- “开启拖尾(Trail)”默认关闭;
|
||||||
|
- 开启后默认创建一张拖尾资源卡片;
|
||||||
|
- 默认图片为 `public/trails/trail.png`;
|
||||||
|
- 默认名称为 `trail`;
|
||||||
|
- 单资源时占比固定为 100%;
|
||||||
|
- 画布“蒙皮网格”显示默认关闭。
|
||||||
|
|
||||||
|
### 2.2 每张拖尾资源的默认参数
|
||||||
|
|
||||||
|
| 参数 | 默认值 |
|
||||||
|
|---|---:|
|
||||||
|
| 图片 | `trail.png` |
|
||||||
|
| 名称 | `trail` |
|
||||||
|
| 占比 | 100%(单资源) |
|
||||||
|
| 占比锁定 | 关闭 |
|
||||||
|
| 旋转 | 0° |
|
||||||
|
| 拖尾骨骼数 | 3 |
|
||||||
|
| 初始长度 | 100 |
|
||||||
|
| 初始宽度 | 100 |
|
||||||
|
| 宽度缩放 | 跟随粒子 |
|
||||||
|
| 生命周期长度曲线 | 关闭,默认水平线 1 |
|
||||||
|
| 形状曲线 | 关闭,默认水平线 1 |
|
||||||
|
| 颜色模式 | 跟随粒子 |
|
||||||
|
| 混合模式 | 正常 |
|
||||||
|
| 固定颜色 | `#ffffff` |
|
||||||
|
| 透明度模式 | 跟随粒子 |
|
||||||
|
| 固定透明度 | 1 |
|
||||||
|
| 透明度曲线 | 默认水平线 1 |
|
||||||
|
|
||||||
|
### 2.3 网格默认参数
|
||||||
|
|
||||||
|
网格拓扑属于拖尾模块的公共结构参数:
|
||||||
|
|
||||||
|
| 参数 | 默认值 | 可调范围 |
|
||||||
|
|---|---:|---:|
|
||||||
|
| 网格行数 | 4 | 2~16 |
|
||||||
|
| 网格列数 | 4 | 2~32 |
|
||||||
|
|
||||||
|
行数决定图片宽度方向的细分,列数决定图片从头部到尾部方向的细分。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、多拖尾图片资源
|
||||||
|
|
||||||
|
### 3.1 资源卡片
|
||||||
|
|
||||||
|
拖尾图片复用了外观与资源模块的通用 `ImageResourceCard.vue`,因此两类图片资源保持统一的交互方式:
|
||||||
|
|
||||||
|
- 点击缩略图选择本地 PNG、JPEG 或 WebP;
|
||||||
|
- 点击右上角 `+` 新增拖尾资源卡片;
|
||||||
|
- 修改资源名称;
|
||||||
|
- 查看当前图片文件名;
|
||||||
|
- 调整资源占比;
|
||||||
|
- 锁定或解除锁定当前占比;
|
||||||
|
- 重置当前资源;
|
||||||
|
- 删除当前资源,至少保留一张。
|
||||||
|
|
||||||
|
拖尾资源不显示“子文件夹”字段,其余通用卡片结构与粒子图片资源一致。
|
||||||
|
|
||||||
|
### 3.2 占比规则
|
||||||
|
|
||||||
|
- 单张拖尾资源时,占比固定为 100%,锁定按钮不可用;
|
||||||
|
- 多张拖尾资源时,总占比始终保持 100%;
|
||||||
|
- 锁定某张资源后,调整其他资源只会重新分配未锁定资源;
|
||||||
|
- 新增、删除或重置资源后会重新平衡占比;
|
||||||
|
- 每个粒子出生时,根据全局随机种子和资源占比选择一张拖尾图片;
|
||||||
|
- 粒子出生后保持已选中的拖尾资源,不会在生命周期中跳换图片。
|
||||||
|
|
||||||
|
### 3.3 卡片重置
|
||||||
|
|
||||||
|
点击拖尾卡片的重置按钮会恢复该卡片的完整默认状态,不再只替换图片。重置内容包括:
|
||||||
|
|
||||||
|
- `trail.png`、名称 `trail`;
|
||||||
|
- 占比和锁定状态;
|
||||||
|
- 旋转、骨骼数、长度、宽度和宽度模式;
|
||||||
|
- 生命周期长度曲线及其开关;
|
||||||
|
- 形状曲线及其开关;
|
||||||
|
- 颜色模式、固定色、颜色渐变和混合模式;
|
||||||
|
- 透明度模式、固定透明度和透明度曲线。
|
||||||
|
|
||||||
|
多资源场景中,卡片恢复默认后仍会执行占比平衡,保证全部资源之和为 100%。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、每张拖尾资源的独立控制
|
||||||
|
|
||||||
|
### 4.1 图片方向
|
||||||
|
|
||||||
|
拖尾图片约定:
|
||||||
|
|
||||||
|
- 图片左侧为拖尾头部;
|
||||||
|
- 图片右侧为拖尾尾部;
|
||||||
|
- 第 0 根拖尾骨骼位于粒子位置;
|
||||||
|
- 后续骨骼沿粒子历史轨迹向后排列。
|
||||||
|
|
||||||
|
“旋转”用于修正所选图片的默认朝向。旋转通过图片中心的 UV 变换完成,不会改变粒子自身旋转。
|
||||||
|
|
||||||
|
### 4.2 骨骼与尺寸
|
||||||
|
|
||||||
|
每张拖尾资源独立设置:
|
||||||
|
|
||||||
|
- **拖尾骨骼数**:决定沿轨迹采样的骨骼节点数量,范围 2~16;
|
||||||
|
- **初始长度**:决定从粒子头部向历史轨迹回溯的距离;
|
||||||
|
- **初始宽度**:决定蒙皮网格横向展开宽度;
|
||||||
|
- **宽度缩放**:可选择固定宽度或跟随粒子当前缩放。
|
||||||
|
|
||||||
|
“跟随粒子”模式使用粒子当前 X/Y 缩放绝对值的平均值作为宽度倍率。
|
||||||
|
|
||||||
|
### 4.3 生命周期长度曲线
|
||||||
|
|
||||||
|
开启后,曲线横轴为粒子归一化生命时间,纵轴为长度倍率:
|
||||||
|
|
||||||
|
```text
|
||||||
|
当前拖尾长度 = 初始长度 × 生命周期长度曲线值
|
||||||
|
```
|
||||||
|
|
||||||
|
曲线默认是值为 1 的水平线。关闭时始终使用初始长度。
|
||||||
|
|
||||||
|
### 4.4 形状曲线
|
||||||
|
|
||||||
|
形状曲线控制拖尾从首端到尾端的宽度变化:
|
||||||
|
|
||||||
|
```text
|
||||||
|
当前列宽度 = 当前拖尾宽度 × 形状曲线值
|
||||||
|
```
|
||||||
|
|
||||||
|
横轴表示从头部到尾部的位置,纵轴表示宽度倍率。默认曲线为值 1 的水平线,因此首尾等宽。
|
||||||
|
|
||||||
|
### 4.5 颜色与混合模式
|
||||||
|
|
||||||
|
每张拖尾资源支持三种颜色模式:
|
||||||
|
|
||||||
|
| 模式 | 行为 |
|
||||||
|
|---|---|
|
||||||
|
| 跟随粒子 | 使用当前粒子图片的实时颜色 |
|
||||||
|
| 固定 | 使用拖尾资源自己的固定颜色 |
|
||||||
|
| 曲线 | 按粒子生命周期使用多色渐变 |
|
||||||
|
|
||||||
|
固定色支持颜色选择器和十六进制文本输入。曲线模式复用生命周期多色渐变控件,可添加多个颜色标记。
|
||||||
|
|
||||||
|
每张资源还可独立选择四种混合模式:正常、相加、相乘、滤色。
|
||||||
|
|
||||||
|
### 4.6 透明度
|
||||||
|
|
||||||
|
每张拖尾资源支持三种透明度模式:
|
||||||
|
|
||||||
|
| 模式 | 行为 |
|
||||||
|
|---|---|
|
||||||
|
| 跟随粒子 | 使用粒子当前透明度 |
|
||||||
|
| 固定 | 使用拖尾资源自己的固定透明度 |
|
||||||
|
| 曲线 | 按粒子生命周期读取透明度曲线 |
|
||||||
|
|
||||||
|
透明度曲线默认是值为 1 的水平线。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、拖尾骨骼与蒙皮实现
|
||||||
|
|
||||||
|
### 5.1 粒子历史轨迹
|
||||||
|
|
||||||
|
每个活动粒子维护自己的轨迹历史:
|
||||||
|
|
||||||
|
- 记录粒子每帧的最终渲染位置;
|
||||||
|
- 相邻位置变化过小时更新首点,避免堆积大量重复点;
|
||||||
|
- 按配置长度保留约 1.5 倍的历史距离;
|
||||||
|
- 单粒子历史点设置上限,防止长时间运行无限增长;
|
||||||
|
- 粒子死亡、拖尾关闭或重新绑定时清空对应历史。
|
||||||
|
|
||||||
|
记录的是粒子最终渲染位置,因此移动噪声造成的视觉位移也会进入拖尾轨迹,拖尾头部不会与画面中的粒子脱节。
|
||||||
|
|
||||||
|
### 5.2 独立骨骼链
|
||||||
|
|
||||||
|
每个粒子根据自己所选拖尾资源的骨骼数生成独立骨骼链:
|
||||||
|
|
||||||
|
1. 第 0 根骨骼固定在粒子最终渲染位置;
|
||||||
|
2. 其余骨骼沿历史轨迹按距离等距采样;
|
||||||
|
3. 每根骨骼根据前后采样点计算旋转方向;
|
||||||
|
4. 不同粒子、不同拖尾资源可以使用不同骨骼数量。
|
||||||
|
|
||||||
|
### 5.3 网格拓扑
|
||||||
|
|
||||||
|
网格根据行列数自动生成:
|
||||||
|
|
||||||
|
- 每个交叉点生成一个顶点;
|
||||||
|
- 相邻四个顶点组成两个三角形;
|
||||||
|
- 列方向对应图片左侧头部到右侧尾部;
|
||||||
|
- 行方向对应图片宽度;
|
||||||
|
- UV 根据资源旋转参数绕中心重新计算。
|
||||||
|
|
||||||
|
### 5.4 蒙皮权重
|
||||||
|
|
||||||
|
每一列顶点绑定相邻两根拖尾骨骼:
|
||||||
|
|
||||||
|
- 根据列在拖尾长度中的归一化位置,计算前后骨骼索引;
|
||||||
|
- 在两根骨骼之间使用线性权重插值;
|
||||||
|
- 顶点中心沿骨骼链移动;
|
||||||
|
- 顶点宽度方向使用轨迹切线的法线展开;
|
||||||
|
- 形状曲线进一步调节每一列的宽度。
|
||||||
|
|
||||||
|
当前使用 CPU 更新 PixiJS `SimpleMesh` 顶点,蒙皮结果实时写回画布。
|
||||||
|
|
||||||
|
### 5.5 绘制层级
|
||||||
|
|
||||||
|
拖尾网格始终绘制在对应粒子图片下方,避免拖尾覆盖粒子头部。图片不可用、轨迹长度不足或最终透明度为 0 时,拖尾网格自动隐藏。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、重新绑定规则
|
||||||
|
|
||||||
|
以下参数改变时会触发拖尾网格和蒙皮重新绑定:
|
||||||
|
|
||||||
|
1. 网格行数;
|
||||||
|
2. 网格列数;
|
||||||
|
3. 当前拖尾资源的骨骼数;
|
||||||
|
4. 当前拖尾资源的旋转方向;
|
||||||
|
5. 粒子切换到结构签名不同的拖尾资源。
|
||||||
|
|
||||||
|
重新绑定时:
|
||||||
|
|
||||||
|
- 保留当前正在使用的图片,不会错误恢复为 `trail.png`;
|
||||||
|
- 重新生成顶点、索引、UV 和骨骼权重;
|
||||||
|
- 清空旧轨迹和旧骨骼状态;
|
||||||
|
- 从粒子当前最终位置重新开始采样;
|
||||||
|
- 避免旧骨骼位置套用到新网格后产生偏移或拉伸。
|
||||||
|
|
||||||
|
只有用户主动点击卡片重置按钮时,图片和参数才会恢复为完整默认状态。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、粒子跟随与修改器协作
|
||||||
|
|
||||||
|
此前出现的“拖尾像独立图片、没有跟随粒子”问题已经修正。当前拖尾更新顺序为:
|
||||||
|
|
||||||
|
1. 计算粒子的基础物理位置;
|
||||||
|
2. 应用重力、风力、力场、吸附和阻尼;
|
||||||
|
3. 计算移动噪声等最终视觉位移;
|
||||||
|
4. 得到粒子最终渲染位置;
|
||||||
|
5. 将该位置写入拖尾历史;
|
||||||
|
6. 重新采样拖尾骨骼;
|
||||||
|
7. 将首骨骼强制对齐粒子最终位置;
|
||||||
|
8. 更新蒙皮顶点、颜色、透明度和混合模式。
|
||||||
|
|
||||||
|
因此拖尾与粒子画面位置使用同一坐标来源。粒子死亡时,对应拖尾历史、骨骼和网格会一起停止并清理。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、固定帧录制与回放
|
||||||
|
|
||||||
|
粒子实时状态新增 `trailState`,记录:
|
||||||
|
|
||||||
|
- 当前拖尾资源 ID;
|
||||||
|
- 拖尾骨骼位置和旋转;
|
||||||
|
- 当前宽度;
|
||||||
|
- 当前透明度;
|
||||||
|
- 当前颜色。
|
||||||
|
|
||||||
|
固定帧录制会保存这些状态,回放时按资源 ID 恢复对应纹理和蒙皮参数,避免多拖尾资源在回放时全部退回第一张图片。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、画布蒙皮网格调试
|
||||||
|
|
||||||
|
画布左上角新增“蒙皮网格”复选框:
|
||||||
|
|
||||||
|
- 默认关闭;
|
||||||
|
- 开启后使用青色线条绘制正在显示的拖尾网格;
|
||||||
|
- 显示网格的行列连接和所有顶点;
|
||||||
|
- 多个粒子的网格可同时显示;
|
||||||
|
- 只用于编辑器调试,不参与录制和导出;
|
||||||
|
- 关闭拖尾或关闭开关时立即清空辅助线。
|
||||||
|
|
||||||
|
该功能用于检查图片方向、网格密度、骨骼变形和拖尾是否与粒子头部正确连接。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、核心数据结构
|
||||||
|
|
||||||
|
### 10.1 `TrailImageResource`
|
||||||
|
|
||||||
|
每张拖尾图片已经成为完整的独立资源配置,主要包含:
|
||||||
|
|
||||||
|
- 资源 ID、纹理、名称和预览地址;
|
||||||
|
- 占比和锁定状态;
|
||||||
|
- 图片旋转;
|
||||||
|
- 颜色模式、固定色、多色渐变和混合模式;
|
||||||
|
- 透明度模式、固定透明度和曲线;
|
||||||
|
- 骨骼数、初始长度、初始宽度和宽度模式;
|
||||||
|
- 生命周期长度曲线;
|
||||||
|
- 首端到尾端的形状曲线。
|
||||||
|
|
||||||
|
### 10.2 `TrailState`
|
||||||
|
|
||||||
|
`TrailState` 是实时预览与时间轴之间的拖尾状态载体,包含骨骼链、宽度、透明度、颜色和拖尾资源 ID。
|
||||||
|
|
||||||
|
### 10.3 兼容迁移
|
||||||
|
|
||||||
|
`ensureEmitterConfig()` 会:
|
||||||
|
|
||||||
|
- 把早期单拖尾字段迁移为 `trailResources`;
|
||||||
|
- 为旧资源补齐旋转、混合模式、独立骨骼尺寸和曲线字段;
|
||||||
|
- 把旧版非水平拖尾曲线迁移为当前默认水平曲线;
|
||||||
|
- 把旧网格默认值迁移为 4 行、4 列;
|
||||||
|
- 保证热更新和旧内存配置不会把 `undefined` 传入运行逻辑。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十一、本阶段新增或重点调整的文件
|
||||||
|
|
||||||
|
| 文件 | 本阶段职责 |
|
||||||
|
|---|---|
|
||||||
|
| `public/trails/trail.png` | 默认拖尾图片资源 |
|
||||||
|
| `src/views/ImageResourceCard.vue` | 粒子图片与拖尾图片共用的资源卡片 |
|
||||||
|
| `src/views/ParticlePanel.vue` | 拖尾开关、多资源卡片、独立参数、曲线、占比和重置逻辑 |
|
||||||
|
| `src/core/particleEmitter.ts` | 轨迹记录、骨骼采样、网格生成、CPU 蒙皮、资源选择、录制和回放状态 |
|
||||||
|
| `src/views/Stage.vue` | 默认拖尾纹理注入、画布蒙皮网格显示及实时刷新 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十二、验证结果
|
||||||
|
|
||||||
|
本阶段已完成以下检查:
|
||||||
|
|
||||||
|
- 默认拖尾资源能加载 `trail.png`;
|
||||||
|
- 图片左侧为头部、右侧为尾部;
|
||||||
|
- 拖尾首骨骼与粒子最终渲染位置保持一致;
|
||||||
|
- 移动噪声开启时,拖尾沿粒子视觉轨迹移动;
|
||||||
|
- 多张拖尾图片可新增、删除、选择和按占比分配;
|
||||||
|
- 每张资源可独立设置骨骼、长度、宽度、旋转、颜色、混合模式和透明度;
|
||||||
|
- 生命周期长度曲线和形状曲线已移动到各自资源卡片;
|
||||||
|
- 修改行列数、骨骼数或旋转后会重新绑定,并保留当前图片;
|
||||||
|
- 卡片重置会恢复图片和全部默认参数;
|
||||||
|
- 网格默认值为 4 × 4;
|
||||||
|
- 画布“蒙皮网格”开关默认关闭,可正常开启和关闭;
|
||||||
|
- 本地页面交互过程中没有控制台错误;
|
||||||
|
- `npm run build` 通过;
|
||||||
|
- `git diff --check` 通过。
|
||||||
|
|
||||||
|
Vite 仍会提示主包体积超过 500 kB,该提示不影响当前功能运行。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十三、当前边界与已知风险
|
||||||
|
|
||||||
|
1. **拖尾不会在粒子死亡后继续残留**:粒子死亡时拖尾一起清理,尚未支持尾迹独立淡出。
|
||||||
|
2. **蒙皮在 CPU 中逐帧更新**:高粒子数、高网格细分和多拖尾资源同时使用时需要性能压测。
|
||||||
|
3. **历史轨迹按帧采样**:极端帧率波动下,弯曲细节可能出现密度差异;当前骨骼按距离重新采样可缓解但不能完全消除。
|
||||||
|
4. **所有拖尾资源共用网格行列数**:骨骼数和外观参数可独立配置,但网格拓扑尚未移动到每张资源卡片。
|
||||||
|
5. **资源仍依赖运行时纹理对象**:本地上传图片尚未进入正式工程保存、加载和资源打包流程。
|
||||||
|
6. **旋转使用 UV 中心变换**:非矩形有效内容或带大面积透明边距的图片可能需要额外裁切、翻转或轴向选项。
|
||||||
|
7. **时间轴保存的是运行状态**:尚未形成独立、版本化、可跨工程交换的拖尾动画格式。
|
||||||
|
8. **缺少自动化测试**:当前以类型检查、生产构建和浏览器交互验证为主。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十四、下一步开发建议
|
||||||
|
|
||||||
|
### 优先级 P0:拖尾性能与稳定性
|
||||||
|
|
||||||
|
- 对 100、200、400 粒子分别测试 4 × 4、8 × 8、16 × 32 网格;
|
||||||
|
- 记录 CPU 蒙皮耗时、顶点数量和内存占用;
|
||||||
|
- 将不在视口内或透明度为 0 的拖尾提前跳过;
|
||||||
|
- 缓存资源 ID 到配置对象的映射,减少每帧数组查找;
|
||||||
|
- 评估把顶点变形迁移到 GPU Shader 的收益。
|
||||||
|
|
||||||
|
### 优先级 P1:拖尾生命周期收尾
|
||||||
|
|
||||||
|
- 增加粒子死亡后拖尾残留时间;
|
||||||
|
- 支持尾迹独立淡出和收缩;
|
||||||
|
- 明确吸附完成、碰撞死亡和自然死亡时的不同拖尾结束策略;
|
||||||
|
- 增加拖尾采样间隔或最小距离参数;
|
||||||
|
- 增加轨迹平滑和尖角处理。
|
||||||
|
|
||||||
|
### 优先级 P2:工程保存与资源打包
|
||||||
|
|
||||||
|
- 定义正式 `ProjectSchema` 和版本号;
|
||||||
|
- 将 Pixi Texture 与可序列化拖尾配置分离;
|
||||||
|
- 保存多拖尾资源、占比、锁定、颜色、曲线和网格参数;
|
||||||
|
- 采用 Data URL、ZIP 工程包或资源目录保存自定义拖尾图片;
|
||||||
|
- 为阶段三到阶段五建立明确的配置迁移测试。
|
||||||
|
|
||||||
|
### 优先级 P3:导出准备
|
||||||
|
|
||||||
|
- 明确拖尾骨骼链在 Spine 中的命名和层级;
|
||||||
|
- 固化网格顶点、三角形、UV、权重和骨骼数据格式;
|
||||||
|
- 将 `TrailState` 烘焙为骨骼关键帧或网格变形关键帧;
|
||||||
|
- 验证多拖尾资源的槽位、附件和混合模式表达;
|
||||||
|
- 评估不同 Spine 版本对加权网格和变形时间轴的兼容性。
|
||||||
|
|
||||||
|
### 优先级 P4:后续模块
|
||||||
|
|
||||||
|
- 实现碰撞体和粒子碰撞响应;
|
||||||
|
- 实现轨道路径与路径跟随;
|
||||||
|
- 为修改器辅助图形增加统一显示入口;
|
||||||
|
- 增加撤销/重做、预设和参数复制粘贴。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十五、阶段五验收标准
|
||||||
|
|
||||||
|
阶段五可视为完成,当以下条件持续满足:
|
||||||
|
|
||||||
|
- 拖尾默认关闭,开启后默认使用 `trail.png`;
|
||||||
|
- 每个粒子拥有独立的轨迹、骨骼链和蒙皮网格;
|
||||||
|
- 拖尾图片左侧连接粒子,右侧沿历史轨迹延伸;
|
||||||
|
- 多拖尾资源按占比确定性选择,并支持占比锁定;
|
||||||
|
- 每张拖尾资源可独立调整旋转、骨骼、尺寸、曲线、颜色、混合模式和透明度;
|
||||||
|
- 网格按行列自动生成,并由相邻骨骼权重驱动;
|
||||||
|
- 拖尾与粒子最终渲染位置保持连接;
|
||||||
|
- 修改结构参数后保留当前图片并重新绑定,不产生旧蒙皮偏移;
|
||||||
|
- 卡片重置可恢复完整默认状态;
|
||||||
|
- 画布可选择显示实际蒙皮网格,且默认关闭;
|
||||||
|
- 拖尾状态能够参与时间轴录制和回放;
|
||||||
|
- 阶段三外观资源和阶段四修改器功能没有发生回归;
|
||||||
|
- TypeScript 类型检查和生产构建保持通过。
|
||||||
Reference in New Issue
Block a user