From f2fe653c04304028fef97484d2a8f5809a94a544 Mon Sep 17 00:00:00 2001 From: tianmo Date: Fri, 28 Aug 2026 17:50:23 +0800 Subject: [PATCH] =?UTF-8?q?=E9=98=B6=E6=AE=B5=E4=BA=94=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/trails/trail.png | Bin 0 -> 25101 bytes src/core/particleEmitter.ts | 539 +++++++++++++++++++++++++++++++- src/views/ImageResourceCard.vue | 106 +++++++ src/views/ParticlePanel.vue | 333 +++++++++++++++++--- src/views/Stage.vue | 57 +++- 网页粒子系统阶段五.md | 448 ++++++++++++++++++++++++++ 6 files changed, 1423 insertions(+), 60 deletions(-) create mode 100644 public/trails/trail.png create mode 100644 src/views/ImageResourceCard.vue create mode 100644 网页粒子系统阶段五.md diff --git a/public/trails/trail.png b/public/trails/trail.png new file mode 100644 index 0000000000000000000000000000000000000000..88fa2084b90c354cfcece4163401e89be138fd4d GIT binary patch literal 25101 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 | 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 { texture: Texture @@ -196,6 +222,32 @@ export interface EmitterConfig { attractionPath: 'linear' | 'curve' /** 开启拖尾 */ trail: boolean + trailConfigVersion: number + trailResources: TrailImageResource[] + trailResourceVersion: number + /** 拖尾使用独立图片和独立网格蒙皮 */ + /** @deprecated 旧版单拖尾资源字段,仅用于配置迁移 */ + trailTexture: Texture | 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 /** 开启路径跟随(预留) */ @@ -214,9 +266,26 @@ export interface ParticleState { alpha: number colorHex: number resourceId?: number + /** 拖尾拥有独立骨骼链和蒙皮状态,录制/回放时一并保存。 */ + trailState?: TrailState 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 { return { texture: null as unknown as Texture, @@ -354,6 +423,51 @@ export function defaultConfig(): EmitterConfig { attractionDelayMode: 'particle', attractionPath: 'curve', 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, pathFollow: false, } @@ -371,6 +485,12 @@ export function ensureEmitterConfig(config: EmitterConfig): EmitterConfig { const legacyWindY = Number(target.windY) const needsForceFieldMigration = target.forceFalloff == null 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 | null | undefined + const legacyTrailTextureName = String(target.trailTextureName || 'trail') + const legacyTrailPreviewUrl = String(target.trailPreviewUrl || '/trails/trail.png') for (const [key, value] of Object.entries(defaults)) { if (target[key] !== undefined && target[key] !== null) continue target[key] = Array.isArray(value) @@ -436,6 +556,80 @@ export function ensureEmitterConfig(config: EmitterConfig): EmitterConfig { if (needsForceFieldMigration) { 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 } @@ -461,6 +655,15 @@ interface Particle { attractionStartWorldX: number attractionStartWorldY: 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 } @@ -473,6 +676,7 @@ export class ParticleEmitter extends Container { private cfg: EmitterConfig private accumulator = 0 private burstTimer = 0 + private _spawnSerial = 0 /** 发射器累计运行时间(秒) — 非响应式,避免污染 config */ private _elapsed = 0 /** 用于“统一时间”延迟:记录吸附从关闭切换为开启时的发射器时间。 */ @@ -484,11 +688,13 @@ export class ParticleEmitter extends Container { /** 确定性随机(种子>0 时可复现) */ private rng: () => number = Math.random private _lastSeed = -1 + private trailDebugGfx = new Graphics() constructor(config: EmitterConfig, maxParticles = MAX_PARTICLES) { super() this.cfg = ensureEmitterConfig(config) this._ensurePool(maxParticles) + this.addChild(this.trailDebugGfx) } /** 仅在种子变化时重建随机源;种子不变时让序列持续推进,保证"确定性随机序列"语义 */ @@ -521,6 +727,15 @@ export class ParticleEmitter extends Container { attractionStartWorldX: 0, attractionStartWorldY: 0, attractionCurveSide: 1, + trailMesh: null, + trailMeshSignature: '', + trailHistory: [], + trailBones: [], + trailSkinWeights: [], + trailCurrentWidth: 0, + trailCurrentAlpha: 0, + trailCurrentColor: 0xffffff, + trailResourceId: 1, resourceId: 1, }) } @@ -536,7 +751,7 @@ export class ParticleEmitter extends Container { cfg.lifeMode == null || cfg.speedMode == null || cfg.scaleMode == null || cfg.initialRotationMode == null || cfg.speedOverLifeEnabled == null || cfg.scaleOverLifeEnabled == null || cfg.rotationOverLifeEnabled == null || - !Array.isArray(cfg.scaleOverLifeCurve) || cfg.attraction == null + !Array.isArray(cfg.scaleOverLifeCurve) || cfg.attraction == null || cfg.trailBoneCount == null ) ensureEmitterConfig(cfg) this.reseed() // 种子变化即重建随机源 // 累计发射时间 @@ -725,12 +940,254 @@ export class ParticleEmitter extends Container { : hexToNumber(colorStart) s.tint = rgb 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 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() { 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 resource = this.pickImageResource() + // 拖尾资源使用独立的确定性采样,不改变粒子属性原有随机序列。 + const trailResource = this.pickTrailResource(seededUnit(cfg.seed, this._spawnSerial++, 0x5bd1e995)) // 噪声相位由全局随机种子和粒子池编号确定,同一配置重复录制得到相同轨迹。 p.noisePhaseX = seededUnit(cfg.seed, idx, 0x68bc21eb) * 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.attractionElapsed = 0 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.x = ox; p.y = oy p.vx = Math.cos(dirAng) * speed @@ -835,14 +1301,45 @@ export class ParticleEmitter extends Container { 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) { const p = this.pool[i] p.active = false p.sprite.visible = false + p.trailHistory.length = 0 + p.trailBones.length = 0 + if (p.trailMesh) p.trailMesh.visible = false } 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() { @@ -857,9 +1354,19 @@ export class ParticleEmitter extends Container { for (const p of this.pool) { if (!p.active) continue 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, 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 @@ -871,7 +1378,11 @@ export class ParticleEmitter extends Container { for (const s of states) byName.set(s.boneName, s) for (const p of this.pool) { 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 s.visible = true 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 } } + 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() { - 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.burstTimer = 0 + this._spawnSerial = 0 this._elapsed = 0 this._attractionEnabledAt = 0 this._attractionWasEnabled = false diff --git a/src/views/ImageResourceCard.vue b/src/views/ImageResourceCard.vue new file mode 100644 index 0000000..84c20b8 --- /dev/null +++ b/src/views/ImageResourceCard.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/src/views/ParticlePanel.vue b/src/views/ParticlePanel.vue index 07a0136..b4a53f0 100644 --- a/src/views/ParticlePanel.vue +++ b/src/views/ParticlePanel.vue @@ -279,44 +279,18 @@ 图片资源 -
-
- -
- - -
-
- - -
-
-
{{ resource.textureName || 'star' }}{{ resource.textureName.includes('.') ? '' : '.png' }}
-
- - -
+ @@ -359,7 +333,7 @@
- + @@ -472,7 +446,94 @@
延迟后从当前位置开始吸附 · 到达红色范围即死亡 · 可在画布内拖动吸附圆
- +
+ +
+
+
拖尾独立图片
+ +
+ +
+ + + + + + + + + + + + +
颜色模式 +
+ + + +
+
+ + + + +
透明度模式 +
+ + + +
+
+ + +
+ + +
+ + +
+
每个粒子生成独立拖尾骨骼链;网格按行列自动重建,并由相邻骨骼权重共同驱动蒙皮变形
+
+
@@ -502,6 +563,7 @@ import NumSlider from './NumSlider.vue' import CurveEditor from './CurveEditor.vue' import ParticleAttributeControl from './ParticleAttributeControl.vue' import ColorGradientEditor from './ColorGradientEditor.vue' +import ImageResourceCard from './ImageResourceCard.vue' 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 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 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>({ scene: false, @@ -595,6 +659,176 @@ function onShapeChange() { // 重置/删除图片 → 回到默认 star.png const textureFileInput = ref(null) const pendingResourceId = ref(null) +const trailTextureFileInput = ref(null) +const pendingTrailResourceId = ref(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() { 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-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; } +.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; } diff --git a/src/views/Stage.vue b/src/views/Stage.vue index 451aebc..76dc77b 100644 --- a/src/views/Stage.vue +++ b/src/views/Stage.vue @@ -7,6 +7,7 @@ +
@@ -63,7 +64,7 @@