@fps-games/vfx 0.1.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/README.md +41 -0
  3. package/dist/effects/coin-burst/index.js +6 -2
  4. package/dist/effects/comet-trail-flame/index.js +3 -1
  5. package/dist/effects/debris-smoke/index.js +1 -0
  6. package/dist/effects/electric-arc/index.js +1 -0
  7. package/dist/effects/energy-shield/index.js +1 -0
  8. package/dist/effects/explosion/index.js +3 -2
  9. package/dist/effects/hit-flash/index.js +12 -4
  10. package/dist/effects/laser-beam/index.js +14 -2
  11. package/dist/effects/lumber_vehicle_upgrade_aura/index.js +1 -0
  12. package/dist/effects/monster-hit/index.js +2 -1
  13. package/dist/effects/player-upgrade/index.js +5 -3
  14. package/dist/effects/pulse-laser-volley/index.js +14 -4
  15. package/dist/effects/thruster-flame/index.js +3 -1
  16. package/dist/effects/treasure-attention/index.d.ts +54 -0
  17. package/dist/effects/treasure-attention/index.js +535 -0
  18. package/dist/effects/ufo-thread-flame/index.js +1 -0
  19. package/dist/effects/vehicle-upgrade/index.js +4 -1
  20. package/dist/effects/weapon-trail/index.js +8 -3
  21. package/dist/effects/wood_crusher_sawdust/index.d.ts +4 -0
  22. package/dist/effects/wood_cutter_sawdust/index.d.ts +4 -0
  23. package/dist/index.js +85 -17
  24. package/dist/types.d.ts +80 -0
  25. package/docs/INTEGRATION.md +194 -0
  26. package/docs/hit-flash-red-method.md +155 -0
  27. package/docs/treasure_attention_fx_babylonjs_guide.md +901 -0
  28. package/docs/vfx-placement-design.md +164 -0
  29. package/index.json +19 -1
  30. package/package.json +5 -1
  31. package/packages/EffectPackageService.ts +123 -17
  32. package/packages/effects/coin-burst/index.ts +7 -2
  33. package/packages/effects/comet-trail-flame/index.ts +3 -2
  34. package/packages/effects/debris-smoke/index.ts +1 -0
  35. package/packages/effects/electric-arc/index.ts +1 -0
  36. package/packages/effects/energy-shield/index.ts +1 -0
  37. package/packages/effects/explosion/index.ts +3 -2
  38. package/packages/effects/hit-flash/index.ts +11 -3
  39. package/packages/effects/laser-beam-factory.ts +14 -3
  40. package/packages/effects/lumber_vehicle_upgrade_aura/index.ts +2 -0
  41. package/packages/effects/monster-hit/index.ts +2 -1
  42. package/packages/effects/player-upgrade/index.ts +8 -3
  43. package/packages/effects/pulse-laser-volley/index.ts +15 -4
  44. package/packages/effects/thruster-flame/index.ts +3 -2
  45. package/packages/effects/treasure-attention/effect.json +7 -0
  46. package/packages/effects/treasure-attention/index.ts +630 -0
  47. package/packages/effects/treasure-attention/vfx-params.json +49 -0
  48. package/packages/effects/ufo-thread-flame/index.ts +1 -0
  49. package/packages/effects/vehicle-upgrade/index.ts +4 -1
  50. package/packages/effects/weapon-trail/index.ts +7 -3
  51. package/packages/types.ts +88 -0
@@ -0,0 +1,630 @@
1
+ import { Constants } from '@babylonjs/core/Engines/constants';
2
+ import { GlowLayer } from '@babylonjs/core/Layers/glowLayer';
3
+ import { Material } from '@babylonjs/core/Materials/material';
4
+ import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial';
5
+ import { DynamicTexture } from '@babylonjs/core/Materials/Textures/dynamicTexture';
6
+ import { Texture } from '@babylonjs/core/Materials/Textures/texture';
7
+ import { Color3, Color4 } from '@babylonjs/core/Maths/math.color';
8
+ import { Vector3 } from '@babylonjs/core/Maths/math.vector';
9
+ import type { AbstractMesh } from '@babylonjs/core/Meshes/abstractMesh';
10
+ import { Mesh } from '@babylonjs/core/Meshes/mesh';
11
+ import { VertexData } from '@babylonjs/core/Meshes/mesh.vertexData';
12
+ import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder';
13
+ import { TransformNode } from '@babylonjs/core/Meshes/transformNode';
14
+ import { ParticleSystem } from '@babylonjs/core/Particles/particleSystem';
15
+ import type { Scene } from '@babylonjs/core/scene';
16
+ import { configureVfxParticleSystem, VFX_RENDERING_GROUP_ID } from '../../rendering';
17
+ import type { VfxColorValue, VfxEffectHandle, VfxEffectPackage, VfxParamValues } from '../../types';
18
+
19
+ export interface TreasureAttentionParams extends VfxParamValues {
20
+ radius: number;
21
+ yOffset: number;
22
+ height: number;
23
+ rays: number;
24
+ rayWidth: number;
25
+ spinSpeed: number;
26
+ color: VfxColorValue;
27
+ auraColor: VfxColorValue;
28
+ ringColor: VfxColorValue;
29
+ rayColor: VfxColorValue;
30
+ beamColor: VfxColorValue;
31
+ opacity: number;
32
+ auraOpacity: number;
33
+ ringOpacity: number;
34
+ beamOpacity: number;
35
+ particleRate: number;
36
+ particleLife: number;
37
+ sparkleMinSize: number;
38
+ sparkleMaxSize: number;
39
+ riseSpeed: number;
40
+ drift: number;
41
+ bloomIntensity: number;
42
+ bloomKernelSize: number;
43
+ }
44
+
45
+ export interface CreateTreasureAttentionOptions {
46
+ root?: TransformNode;
47
+ target?: TransformNode | AbstractMesh | null;
48
+ position?: Vector3;
49
+ params?: Partial<TreasureAttentionParams>;
50
+ renderingGroupId?: number;
51
+ glowLayer?: GlowLayer | null;
52
+ disposeRoot?: boolean;
53
+ /** 经 @fps/vfx 包播放时为 true:root 的 transform 归 service 独占(placement/挂载由 service 管),特效不写 root.position(G6)。 */
54
+ serviceOwnedRoot?: boolean;
55
+ }
56
+
57
+ export interface TreasureAttentionHandle extends VfxEffectHandle {
58
+ root: TransformNode;
59
+ particleSystem: ParticleSystem;
60
+ update(params: Partial<TreasureAttentionParams>): void;
61
+ setEnabled(enabled: boolean): void;
62
+ setTarget(target: TransformNode | AbstractMesh | null): void;
63
+ setPosition(position: Vector3): void;
64
+ }
65
+
66
+ const DEFAULT_PARAMS: TreasureAttentionParams = {
67
+ radius: 1.15,
68
+ yOffset: 0.05,
69
+ height: 1.35,
70
+ rays: 9,
71
+ rayWidth: 0.34,
72
+ spinSpeed: 0.85,
73
+ color: { r: 1, g: 0.9, b: 0.22 },
74
+ auraColor: { r: 1, g: 0.82, b: 0.18 },
75
+ ringColor: { r: 1, g: 0.95, b: 0.38 },
76
+ rayColor: { r: 1, g: 0.76, b: 0.12 },
77
+ beamColor: { r: 1, g: 0.92, b: 0.32 },
78
+ opacity: 1,
79
+ auraOpacity: 0.82,
80
+ ringOpacity: 0.95,
81
+ beamOpacity: 0.68,
82
+ particleRate: 42,
83
+ particleLife: 0.82,
84
+ sparkleMinSize: 0.045,
85
+ sparkleMaxSize: 0.13,
86
+ riseSpeed: 0.72,
87
+ drift: 0.12,
88
+ bloomIntensity: 0.55,
89
+ bloomKernelSize: 32,
90
+ };
91
+
92
+ export const treasureAttentionEffectPackage: VfxEffectPackage<TreasureAttentionParams> = {
93
+ id: 'treasure-attention',
94
+ nameZh: '宝藏注意力光效',
95
+ placement: 'socket', // 附着在宝藏上的注意力标记:geometry 挂 fxRoot 子节点承 yOffset,root 交给 service(挂载/位置由 service 管)
96
+ defaultParams: DEFAULT_PARAMS,
97
+ debugAnchor: 'playerRight',
98
+ manifest: {
99
+ id: 'treasure-attention',
100
+ nameZh: '宝藏注意力光效',
101
+ source: 'docs/treasure_attention_fx_babylonjs_guide.md',
102
+ },
103
+ debugParams: [
104
+ { key: 'radius', label: '光效半径', kind: 'scale', min: 0.35, max: 3.5, step: 0.01 },
105
+ { key: 'yOffset', label: '离地高度', kind: 'number', min: -0.1, max: 0.4, step: 0.005 },
106
+ { key: 'height', label: '竖光高度', kind: 'scale', min: 0.2, max: 3.5, step: 0.01 },
107
+ { key: 'rays', label: '星芒数量', kind: 'number', min: 0, max: 18, step: 1 },
108
+ { key: 'rayWidth', label: '星芒宽度', kind: 'scale', min: 0.05, max: 1, step: 0.01 },
109
+ { key: 'spinSpeed', label: '旋转速度', kind: 'number', min: -3, max: 3, step: 0.01 },
110
+ { key: 'color', label: '上浮星星颜色', kind: 'color' },
111
+ { key: 'auraColor', label: '地面柔光颜色', kind: 'color' },
112
+ { key: 'ringColor', label: '光环颜色', kind: 'color' },
113
+ { key: 'rayColor', label: '旋转星芒颜色', kind: 'color' },
114
+ { key: 'beamColor', label: '竖向光柱颜色', kind: 'color' },
115
+ { key: 'opacity', label: '整体透明度', kind: 'opacity', min: 0, max: 1.5, step: 0.01 },
116
+ { key: 'auraOpacity', label: '地面柔光', kind: 'opacity', min: 0, max: 1.5, step: 0.01 },
117
+ { key: 'ringOpacity', label: '光环亮度', kind: 'opacity', min: 0, max: 1.5, step: 0.01 },
118
+ { key: 'beamOpacity', label: '竖光亮度', kind: 'opacity', min: 0, max: 1.5, step: 0.01 },
119
+ { key: 'particleRate', label: '闪片频率', kind: 'number', min: 0, max: 120, step: 1 },
120
+ { key: 'particleLife', label: '闪片寿命', kind: 'lifetime', min: 0.2, max: 2.5, step: 0.01 },
121
+ { key: 'sparkleMinSize', label: '闪片最小', kind: 'scale', min: 0.01, max: 0.5, step: 0.005 },
122
+ { key: 'sparkleMaxSize', label: '闪片最大', kind: 'scale', min: 0.01, max: 0.8, step: 0.005 },
123
+ { key: 'riseSpeed', label: '上浮速度', kind: 'number', min: 0, max: 2.5, step: 0.01 },
124
+ { key: 'drift', label: '横向漂移', kind: 'number', min: 0, max: 1.2, step: 0.01 },
125
+ { key: 'bloomIntensity', label: 'Bloom 强度', kind: 'number', min: 0, max: 2.5, step: 0.01 },
126
+ { key: 'bloomKernelSize', label: 'Bloom 半径', kind: 'number', min: 8, max: 96, step: 1 },
127
+ ],
128
+ create({ scene, root, position, renderingGroupId, params }) {
129
+ return createTreasureAttentionFx(scene, { root, position, renderingGroupId, params, serviceOwnedRoot: true });
130
+ },
131
+ };
132
+
133
+ export default treasureAttentionEffectPackage;
134
+
135
+ export function createTreasureAttentionFx(scene: Scene, options: CreateTreasureAttentionOptions = {}): TreasureAttentionHandle {
136
+ let params = mergeParams(options.params);
137
+ const root = options.root ?? new TransformNode('vfx_treasureAttention_root', scene);
138
+ const disposeRoot = options.disposeRoot ?? true;
139
+ const renderingGroupId = options.renderingGroupId ?? VFX_RENDERING_GROUP_ID;
140
+ const sharedGlowLayer = options.glowLayer ?? null;
141
+ let target = options.target ?? null;
142
+ const serviceOwnedRoot = options.serviceOwnedRoot ?? false;
143
+ const basePosition = (options.position ?? root.position).clone();
144
+ // 内部锚:承载所有 geometry + yOffset 悬浮。root 留给 service(placement/挂载);standalone 才自管 root.position。
145
+ const fxRoot = new TransformNode('vfx_treasureAttention_fxRoot', scene);
146
+ fxRoot.parent = root;
147
+ let enabled = true;
148
+ let disposed = false;
149
+ let elapsed = 0;
150
+ let raySignature = '';
151
+
152
+ const textures = new Set<DynamicTexture>();
153
+ const materials = new Set<StandardMaterial>();
154
+ const meshes = new Set<Mesh>();
155
+ let rayMeshes: Mesh[] = [];
156
+ let glowLayer: GlowLayer | null = null;
157
+ let ownsGlowLayer = false;
158
+
159
+ const raysRoot = new TransformNode('vfx_treasureAttention_raysRoot', scene);
160
+ raysRoot.parent = fxRoot;
161
+
162
+ const auraTex = makeTexture(scene, textures, 'vfx_treasureAttention_auraTex', 256, (ctx, size) => {
163
+ drawAuraTexture(ctx, size, DEFAULT_PARAMS.auraColor);
164
+ });
165
+ const ringTex = makeTexture(scene, textures, 'vfx_treasureAttention_ringTex', 256, (ctx, size) => {
166
+ drawRingTexture(ctx, size, DEFAULT_PARAMS.ringColor);
167
+ });
168
+ const rayTex = makeTexture(scene, textures, 'vfx_treasureAttention_rayTex', 256, (ctx, size) => {
169
+ drawRayTexture(ctx, size, DEFAULT_PARAMS.rayColor);
170
+ });
171
+ const beamTex = makeTexture(scene, textures, 'vfx_treasureAttention_beamTex', 256, (ctx, size) => {
172
+ drawBeamTexture(ctx, size, DEFAULT_PARAMS.beamColor);
173
+ });
174
+ const sparkleTex = makeTexture(scene, textures, 'vfx_treasureAttention_sparkleTex', 128, drawSparkleTexture);
175
+
176
+ const auraMat = makeAdditiveMaterial(scene, materials, 'vfx_treasureAttention_auraMat', auraTex);
177
+ const ringMat = makeAdditiveMaterial(scene, materials, 'vfx_treasureAttention_ringMat', ringTex);
178
+ const rayMat = makeAdditiveMaterial(scene, materials, 'vfx_treasureAttention_rayMat', rayTex);
179
+ const beamMat = makeAdditiveMaterial(scene, materials, 'vfx_treasureAttention_beamMat', beamTex);
180
+
181
+ const aura = addMesh(MeshBuilder.CreatePlane('vfx_treasureAttention_aura', { size: 1 }, scene));
182
+ aura.rotation.x = Math.PI / 2;
183
+ aura.position.y = 0.025;
184
+ aura.material = auraMat;
185
+ aura.parent = fxRoot;
186
+ aura.alphaIndex = 0;
187
+
188
+ const ring = addMesh(MeshBuilder.CreatePlane('vfx_treasureAttention_ring', { size: 1 }, scene));
189
+ ring.rotation.x = Math.PI / 2;
190
+ ring.position.y = 0.04;
191
+ ring.material = ringMat;
192
+ ring.parent = fxRoot;
193
+ ring.alphaIndex = 1;
194
+
195
+ const beam = addMesh(MeshBuilder.CreatePlane('vfx_treasureAttention_beam', { size: 1 }, scene));
196
+ beam.position.y = params.height * 0.52;
197
+ beam.billboardMode = Mesh.BILLBOARDMODE_Y;
198
+ beam.material = beamMat;
199
+ beam.parent = fxRoot;
200
+ beam.alphaIndex = 3;
201
+
202
+ const particleEmitter = addMesh(new Mesh('vfx_treasureAttention_particleEmitter', scene), false);
203
+ particleEmitter.isVisible = false;
204
+ particleEmitter.parent = fxRoot;
205
+
206
+ const particleSystem = new ParticleSystem('vfx_treasureAttention_sparkles', 180, scene);
207
+ configureVfxParticleSystem(particleSystem);
208
+ particleSystem.particleTexture = sparkleTex;
209
+ particleSystem.emitter = particleEmitter;
210
+ particleSystem.blendMode = ParticleSystem.BLENDMODE_ADD;
211
+ particleSystem.updateSpeed = 1 / 60;
212
+ particleSystem.minAngularSpeed = -4;
213
+ particleSystem.maxAngularSpeed = 4;
214
+ particleSystem.gravity = new Vector3(0, -0.04, 0);
215
+ particleSystem.start();
216
+
217
+ applyParams();
218
+ syncRoot();
219
+
220
+ const observer = scene.onBeforeRenderObservable.add(() => {
221
+ if (disposed || !enabled) return;
222
+ const dt = Math.min(scene.getEngine().getDeltaTime() / 1000, 0.05);
223
+ elapsed += dt;
224
+ syncRoot();
225
+ animate(dt);
226
+ });
227
+
228
+ return {
229
+ root,
230
+ particleSystem,
231
+ update(partial: Partial<TreasureAttentionParams>) {
232
+ if (disposed) return;
233
+ params = mergeParams(partial, params);
234
+ applyParams();
235
+ syncRoot();
236
+ },
237
+ setEnabled(nextEnabled: boolean) {
238
+ enabled = nextEnabled;
239
+ root.setEnabled(nextEnabled);
240
+ if (nextEnabled) {
241
+ particleSystem.start();
242
+ } else {
243
+ particleSystem.stop();
244
+ }
245
+ },
246
+ setTarget(nextTarget: TransformNode | AbstractMesh | null) {
247
+ target = nextTarget;
248
+ syncRoot();
249
+ },
250
+ setPosition(position: Vector3) {
251
+ basePosition.copyFrom(position);
252
+ syncRoot();
253
+ },
254
+ dispose() {
255
+ disposeAll();
256
+ },
257
+ };
258
+
259
+ function addMesh<T extends Mesh>(mesh: T, includeInGlow = true): T {
260
+ mesh.isPickable = false;
261
+ mesh.alwaysSelectAsActiveMesh = true;
262
+ mesh.renderingGroupId = renderingGroupId;
263
+ meshes.add(mesh);
264
+ if (includeInGlow) addGlowMesh(mesh);
265
+ return mesh;
266
+ }
267
+
268
+ function addGlowMesh(mesh: Mesh): void {
269
+ if (!glowLayer || !mesh.material) return;
270
+ glowLayer.addIncludedOnlyMesh(mesh);
271
+ glowLayer.referenceMeshToUseItsOwnMaterial(mesh);
272
+ }
273
+
274
+ function removeGlowMesh(mesh: Mesh): void {
275
+ glowLayer?.removeIncludedOnlyMesh(mesh);
276
+ }
277
+
278
+ function applyParams(): void {
279
+ redrawTexture(auraTex, 256, (ctx, size) => drawAuraTexture(ctx, size, params.auraColor));
280
+ redrawTexture(ringTex, 256, (ctx, size) => drawRingTexture(ctx, size, params.ringColor));
281
+ redrawTexture(rayTex, 256, (ctx, size) => drawRayTexture(ctx, size, params.rayColor));
282
+ redrawTexture(beamTex, 256, (ctx, size) => drawBeamTexture(ctx, size, params.beamColor));
283
+ auraMat.alpha = clamp(params.opacity * params.auraOpacity, 0, 1);
284
+ ringMat.alpha = clamp(params.opacity * params.ringOpacity, 0, 1);
285
+ rayMat.alpha = clamp(params.opacity * 0.9, 0, 1);
286
+ beamMat.alpha = clamp(params.opacity * params.beamOpacity, 0, 1);
287
+
288
+ fxRoot.position.y = params.yOffset; // 悬浮高度统一在内部锚上(不写 root,root 归 service)
289
+ aura.position.y = 0.015;
290
+ ring.position.y = aura.position.y + 0.018;
291
+ beam.position.y = params.height * 0.52;
292
+ beam.scaling.set(Math.max(0.02, params.radius * 1.05), Math.max(0.02, params.height), 1);
293
+
294
+ const particleLife = Math.max(0.04, params.particleLife);
295
+ const minSize = Math.max(0.001, Math.min(params.sparkleMinSize, params.sparkleMaxSize));
296
+ const maxSize = Math.max(minSize, Math.max(params.sparkleMinSize, params.sparkleMaxSize));
297
+ particleSystem.emitRate = Math.max(0, params.particleRate);
298
+ particleSystem.minLifeTime = particleLife * 0.75;
299
+ particleSystem.maxLifeTime = particleLife * 1.2;
300
+ particleSystem.minSize = minSize;
301
+ particleSystem.maxSize = maxSize;
302
+ particleSystem.minEmitBox = new Vector3(-params.radius * 0.45, 0.15, -params.radius * 0.45);
303
+ particleSystem.maxEmitBox = new Vector3(params.radius * 0.45, 0.55, params.radius * 0.45);
304
+ particleSystem.direction1 = new Vector3(-params.drift, params.riseSpeed * 0.76, -params.drift);
305
+ particleSystem.direction2 = new Vector3(params.drift, params.riseSpeed * 1.24, params.drift);
306
+ particleSystem.minEmitPower = Math.max(0.01, params.riseSpeed * 0.28);
307
+ particleSystem.maxEmitPower = Math.max(particleSystem.minEmitPower, params.riseSpeed * 0.58);
308
+ particleSystem.color1 = tintColor(params.color, 1.15, 0.9 * params.opacity);
309
+ particleSystem.color2 = tintColor(params.color, 1.55, 0.76 * params.opacity);
310
+ particleSystem.colorDead = tintColor(params.color, 0.8, 0);
311
+
312
+ rebuildRaysIfNeeded();
313
+ syncGlowLayer();
314
+ }
315
+
316
+ function rebuildRaysIfNeeded(): void {
317
+ const rayCount = clamp(Math.round(params.rays), 0, 32);
318
+ const signature = `${rayCount}:${params.radius.toFixed(3)}:${params.rayWidth.toFixed(3)}`;
319
+ if (signature === raySignature) return;
320
+ raySignature = signature;
321
+ for (const ray of rayMeshes) {
322
+ removeGlowMesh(ray);
323
+ meshes.delete(ray);
324
+ ray.dispose(false, false);
325
+ }
326
+ rayMeshes = [];
327
+ for (let i = 0; i < rayCount; i += 1) {
328
+ const angle = (i / Math.max(1, rayCount)) * Math.PI * 2;
329
+ const length = params.radius * (0.95 + (i % 3) * 0.13);
330
+ const width = params.rayWidth * (0.8 + (i % 2) * 0.22);
331
+ const ray = createRayMesh(scene, `vfx_treasureAttention_ray_${i}`, length, width);
332
+ ray.parent = raysRoot;
333
+ ray.rotation.y = angle;
334
+ ray.position.y = ring.position.y + 0.014 + i * 0.001;
335
+ ray.material = rayMat;
336
+ ray.alphaIndex = 2;
337
+ addMesh(ray);
338
+ rayMeshes.push(ray);
339
+ }
340
+ }
341
+
342
+ function syncGlowLayer(): void {
343
+ const shouldGlow = params.bloomIntensity > 0;
344
+ if (!shouldGlow) {
345
+ if (glowLayer) {
346
+ for (const mesh of meshes) removeGlowMesh(mesh);
347
+ }
348
+ if (ownsGlowLayer) glowLayer?.dispose();
349
+ glowLayer = null;
350
+ ownsGlowLayer = false;
351
+ return;
352
+ }
353
+
354
+ if (!glowLayer) {
355
+ if (sharedGlowLayer) {
356
+ glowLayer = sharedGlowLayer;
357
+ } else {
358
+ glowLayer = new GlowLayer(`vfx_treasureAttention_glow_${root.uniqueId}`, scene, {
359
+ blurKernelSize: params.bloomKernelSize,
360
+ mainTextureRatio: 0.4,
361
+ });
362
+ ownsGlowLayer = true;
363
+ }
364
+ for (const mesh of meshes) addGlowMesh(mesh);
365
+ }
366
+
367
+ if (ownsGlowLayer && glowLayer) {
368
+ glowLayer.intensity = params.bloomIntensity;
369
+ glowLayer.blurKernelSize = params.bloomKernelSize;
370
+ }
371
+ }
372
+
373
+ function syncRoot(): void {
374
+ // service 路径:root 的位置/挂载由 service 独占(placement socket),特效不碰 root(G6)。yOffset 已在 fxRoot 上。
375
+ if (serviceOwnedRoot) return;
376
+ // standalone 路径(直接调 createTreasureAttentionFx):自管 root 位置;target 给了就跟随,否则停在 basePosition。
377
+ if (target) { root.position.copyFrom(target.getAbsolutePosition()); return; }
378
+ root.position.copyFrom(basePosition);
379
+ }
380
+
381
+ function animate(dt: number): void {
382
+ const pulse = 0.5 + 0.5 * Math.sin(elapsed * 5.2);
383
+ const auraSize = params.radius * 2.55 * (0.96 + pulse * 0.11);
384
+ const ringSize = params.radius * 2.15 * (0.94 + pulse * 0.08);
385
+ aura.scaling.set(auraSize, auraSize, 1);
386
+ aura.visibility = clamp((0.72 + pulse * 0.24) * params.opacity * params.auraOpacity, 0, 1);
387
+ ring.rotation.z += dt * 0.65;
388
+ ring.scaling.set(ringSize, ringSize, 1);
389
+ ring.visibility = clamp((0.55 + pulse * 0.36) * params.opacity * params.ringOpacity, 0, 1);
390
+ beam.scaling.x = Math.max(0.02, params.radius * 1.05) * (0.85 + pulse * 0.28);
391
+ beam.scaling.y = Math.max(0.02, params.height);
392
+ beam.visibility = clamp((0.35 + pulse * 0.28) * params.opacity * params.beamOpacity, 0, 1);
393
+ raysRoot.rotation.y += dt * params.spinSpeed;
394
+
395
+ for (let i = 0; i < rayMeshes.length; i += 1) {
396
+ const q = 0.5 + 0.5 * Math.sin(elapsed * 4.4 + i * 0.9);
397
+ const ray = rayMeshes[i];
398
+ ray.visibility = clamp((0.42 + q * 0.42) * params.opacity, 0, 1);
399
+ ray.scaling.x = 0.92 + q * 0.18;
400
+ ray.scaling.z = 0.82 + q * 0.35;
401
+ }
402
+
403
+ if (ownsGlowLayer && glowLayer) {
404
+ glowLayer.intensity = params.bloomIntensity * (0.75 + pulse * 0.35);
405
+ }
406
+ }
407
+
408
+ function disposeAll(): void {
409
+ if (disposed) return;
410
+ disposed = true;
411
+ scene.onBeforeRenderObservable.remove(observer);
412
+ particleSystem.stop();
413
+ particleSystem.dispose(false);
414
+ if (glowLayer) {
415
+ for (const mesh of meshes) removeGlowMesh(mesh);
416
+ }
417
+ if (ownsGlowLayer) glowLayer?.dispose();
418
+ for (const mesh of meshes) {
419
+ mesh.dispose(false, false);
420
+ }
421
+ raysRoot.dispose();
422
+ for (const material of materials) material.dispose();
423
+ for (const texture of textures) texture.dispose();
424
+ if (disposeRoot) root.dispose();
425
+ }
426
+ }
427
+
428
+ function makeTexture(
429
+ scene: Scene,
430
+ textures: Set<DynamicTexture>,
431
+ name: string,
432
+ size: number,
433
+ draw: (ctx: CanvasRenderingContext2D, size: number) => void,
434
+ ): DynamicTexture {
435
+ const texture = new DynamicTexture(name, { width: size, height: size }, scene, false);
436
+ texture.hasAlpha = true;
437
+ texture.wrapU = Texture.CLAMP_ADDRESSMODE;
438
+ texture.wrapV = Texture.CLAMP_ADDRESSMODE;
439
+ const context = texture.getContext() as unknown as CanvasRenderingContext2D;
440
+ context.clearRect(0, 0, size, size);
441
+ draw(context, size);
442
+ texture.update(false);
443
+ textures.add(texture);
444
+ return texture;
445
+ }
446
+
447
+ function makeAdditiveMaterial(
448
+ scene: Scene,
449
+ materials: Set<StandardMaterial>,
450
+ name: string,
451
+ texture: DynamicTexture,
452
+ ): StandardMaterial {
453
+ const material = new StandardMaterial(name, scene);
454
+ material.diffuseTexture = texture;
455
+ material.emissiveTexture = texture;
456
+ material.diffuseColor = Color3.White();
457
+ material.emissiveColor = Color3.White();
458
+ material.specularColor = Color3.Black();
459
+ material.disableLighting = true;
460
+ material.backFaceCulling = false;
461
+ material.useAlphaFromDiffuseTexture = true;
462
+ material.transparencyMode = Material.MATERIAL_ALPHABLEND;
463
+ material.alphaMode = Constants.ALPHA_ADD;
464
+ material.disableDepthWrite = true;
465
+ materials.add(material);
466
+ return material;
467
+ }
468
+
469
+ function createRayMesh(scene: Scene, name: string, length: number, width: number): Mesh {
470
+ const mesh = new Mesh(name, scene);
471
+ const positions = [
472
+ 0, 0, -width * 0.5,
473
+ length, 0, -width * 0.12,
474
+ length, 0, width * 0.12,
475
+ 0, 0, width * 0.5,
476
+ ];
477
+ const vertexData = new VertexData();
478
+ vertexData.positions = positions;
479
+ vertexData.indices = [0, 1, 2, 0, 2, 3];
480
+ vertexData.normals = [
481
+ 0, 1, 0,
482
+ 0, 1, 0,
483
+ 0, 1, 0,
484
+ 0, 1, 0,
485
+ ];
486
+ vertexData.uvs = [0, 0, 1, 0, 1, 1, 0, 1];
487
+ vertexData.applyToMesh(mesh, false);
488
+ return mesh;
489
+ }
490
+
491
+ function redrawTexture(
492
+ texture: DynamicTexture,
493
+ size: number,
494
+ draw: (ctx: CanvasRenderingContext2D, size: number) => void,
495
+ ): void {
496
+ const context = texture.getContext() as unknown as CanvasRenderingContext2D;
497
+ context.clearRect(0, 0, size, size);
498
+ draw(context, size);
499
+ texture.update(false);
500
+ }
501
+
502
+ function drawAuraTexture(ctx: CanvasRenderingContext2D, size: number, color: VfxColorValue): void {
503
+ const center = size / 2;
504
+ const gradient = ctx.createRadialGradient(center, center, 0, center, center, center);
505
+ gradient.addColorStop(0, colorRgba(color, 0.86));
506
+ gradient.addColorStop(0.25, colorRgba(color, 0.46));
507
+ gradient.addColorStop(0.55, colorRgba(color, 0.16));
508
+ gradient.addColorStop(1, colorRgba(color, 0));
509
+ ctx.fillStyle = gradient;
510
+ ctx.fillRect(0, 0, size, size);
511
+ }
512
+
513
+ function drawRingTexture(ctx: CanvasRenderingContext2D, size: number, color: VfxColorValue): void {
514
+ const center = size / 2;
515
+ ctx.shadowColor = colorRgba(color, 0.9);
516
+ ctx.shadowBlur = size * 0.05;
517
+ ctx.strokeStyle = colorRgba(color, 0.78);
518
+ ctx.lineWidth = size * 0.035;
519
+ ctx.beginPath();
520
+ ctx.arc(center, center, size * 0.31, 0, Math.PI * 2);
521
+ ctx.stroke();
522
+ ctx.lineWidth = size * 0.012;
523
+ ctx.strokeStyle = colorRgba(lightenColor(color, 0.58), 0.85);
524
+ ctx.beginPath();
525
+ ctx.arc(center, center, size * 0.42, 0, Math.PI * 2);
526
+ ctx.stroke();
527
+ }
528
+
529
+ function drawRayTexture(ctx: CanvasRenderingContext2D, size: number, color: VfxColorValue): void {
530
+ const gradient = ctx.createLinearGradient(0, 0, size, 0);
531
+ gradient.addColorStop(0, colorRgba(color, 0));
532
+ gradient.addColorStop(0.18, colorRgba(lightenColor(color, 0.35), 0.72));
533
+ gradient.addColorStop(0.55, colorRgba(color, 0.34));
534
+ gradient.addColorStop(1, colorRgba(color, 0));
535
+ ctx.fillStyle = gradient;
536
+ ctx.beginPath();
537
+ ctx.moveTo(0, size * 0.5);
538
+ ctx.quadraticCurveTo(size * 0.45, size * 0.1, size, size * 0.18);
539
+ ctx.lineTo(size, size * 0.82);
540
+ ctx.quadraticCurveTo(size * 0.45, size * 0.9, 0, size * 0.5);
541
+ ctx.closePath();
542
+ ctx.fill();
543
+ }
544
+
545
+ function drawBeamTexture(ctx: CanvasRenderingContext2D, size: number, color: VfxColorValue): void {
546
+ const center = size / 2;
547
+ const radial = ctx.createRadialGradient(center, center, 0, center, center, center * 0.55);
548
+ radial.addColorStop(0, colorRgba(lightenColor(color, 0.65), 0.75));
549
+ radial.addColorStop(0.28, colorRgba(color, 0.35));
550
+ radial.addColorStop(1, colorRgba(color, 0));
551
+ ctx.fillStyle = radial;
552
+ ctx.fillRect(0, 0, size, size);
553
+
554
+ const vertical = ctx.createLinearGradient(0, 0, 0, size);
555
+ vertical.addColorStop(0, colorRgba(color, 0));
556
+ vertical.addColorStop(0.42, colorRgba(lightenColor(color, 0.42), 0.42));
557
+ vertical.addColorStop(0.62, colorRgba(lightenColor(color, 0.42), 0.42));
558
+ vertical.addColorStop(1, colorRgba(color, 0));
559
+ ctx.fillStyle = vertical;
560
+ ctx.fillRect(size * 0.43, 0, size * 0.14, size);
561
+ }
562
+
563
+ function drawSparkleTexture(ctx: CanvasRenderingContext2D, size: number): void {
564
+ const center = size / 2;
565
+ ctx.save();
566
+ ctx.translate(center, center);
567
+ ctx.shadowColor = 'rgba(255,255,255,1)';
568
+ ctx.shadowBlur = size * 0.12;
569
+ ctx.fillStyle = 'rgba(255,255,255,0.95)';
570
+ ctx.beginPath();
571
+ for (let i = 0; i < 8; i += 1) {
572
+ const angle = i * Math.PI / 4;
573
+ const radius = i % 2 === 0 ? size * 0.42 : size * 0.12;
574
+ const x = Math.cos(angle) * radius;
575
+ const y = Math.sin(angle) * radius;
576
+ if (i === 0) ctx.moveTo(x, y);
577
+ else ctx.lineTo(x, y);
578
+ }
579
+ ctx.closePath();
580
+ ctx.fill();
581
+ ctx.restore();
582
+ }
583
+
584
+ function mergeParams(
585
+ patch: Partial<TreasureAttentionParams> | undefined,
586
+ base: TreasureAttentionParams = DEFAULT_PARAMS,
587
+ ): TreasureAttentionParams {
588
+ return {
589
+ ...base,
590
+ ...patch,
591
+ color: copyColor(patch?.color, base.color),
592
+ auraColor: copyColor(patch?.auraColor, base.auraColor),
593
+ ringColor: copyColor(patch?.ringColor, base.ringColor),
594
+ rayColor: copyColor(patch?.rayColor, base.rayColor),
595
+ beamColor: copyColor(patch?.beamColor, base.beamColor),
596
+ };
597
+ }
598
+
599
+ function copyColor(value: VfxColorValue | undefined, fallback: VfxColorValue): VfxColorValue {
600
+ return value ? { ...value } : { ...fallback };
601
+ }
602
+
603
+ function tintColor(color: VfxColorValue, brightness: number, alpha: number): Color4 {
604
+ return new Color4(
605
+ Math.max(0, color.r * brightness),
606
+ Math.max(0, color.g * brightness),
607
+ Math.max(0, color.b * brightness),
608
+ clamp(alpha, 0, 1),
609
+ );
610
+ }
611
+
612
+ function colorRgba(color: VfxColorValue, alpha: number): string {
613
+ const r = Math.round(clamp(color.r, 0, 1) * 255);
614
+ const g = Math.round(clamp(color.g, 0, 1) * 255);
615
+ const b = Math.round(clamp(color.b, 0, 1) * 255);
616
+ return `rgba(${r},${g},${b},${clamp(alpha, 0, 1)})`;
617
+ }
618
+
619
+ function lightenColor(color: VfxColorValue, amount: number): VfxColorValue {
620
+ const t = clamp(amount, 0, 1);
621
+ return {
622
+ r: color.r + (1 - color.r) * t,
623
+ g: color.g + (1 - color.g) * t,
624
+ b: color.b + (1 - color.b) * t,
625
+ };
626
+ }
627
+
628
+ function clamp(value: number, min: number, max: number): number {
629
+ return Math.min(max, Math.max(min, value));
630
+ }
@@ -0,0 +1,49 @@
1
+ {
2
+ "schemaVersion": "vfx-params/1.0",
3
+ "effectId": "treasure-attention",
4
+ "params": {
5
+ "radius": 1.15,
6
+ "yOffset": 0.05,
7
+ "height": 1.35,
8
+ "rays": 9,
9
+ "rayWidth": 0.34,
10
+ "spinSpeed": 0.85,
11
+ "color": {
12
+ "r": 1,
13
+ "g": 0.9,
14
+ "b": 0.22
15
+ },
16
+ "auraColor": {
17
+ "r": 1,
18
+ "g": 0.82,
19
+ "b": 0.18
20
+ },
21
+ "ringColor": {
22
+ "r": 1,
23
+ "g": 0.95,
24
+ "b": 0.38
25
+ },
26
+ "rayColor": {
27
+ "r": 1,
28
+ "g": 0.76,
29
+ "b": 0.12
30
+ },
31
+ "beamColor": {
32
+ "r": 1,
33
+ "g": 0.92,
34
+ "b": 0.32
35
+ },
36
+ "opacity": 1,
37
+ "auraOpacity": 0.82,
38
+ "ringOpacity": 0.95,
39
+ "beamOpacity": 0.68,
40
+ "particleRate": 42,
41
+ "particleLife": 0.82,
42
+ "sparkleMinSize": 0.045,
43
+ "sparkleMaxSize": 0.13,
44
+ "riseSpeed": 0.72,
45
+ "drift": 0.12,
46
+ "bloomIntensity": 0.55,
47
+ "bloomKernelSize": 32
48
+ }
49
+ }
@@ -51,6 +51,7 @@ function excludeFromGlow(scene: Scene, mesh: AbstractMesh): void {
51
51
  export const ufoThreadFlameEffectPackage: VfxEffectPackage<UfoThreadFlameParams> = {
52
52
  id: 'ufo-thread-flame',
53
53
  nameZh: 'UFO丝束尾焰',
54
+ placement: 'socket',
54
55
  debugAnchor: 'player',
55
56
  defaultParams: DEFAULT_PARAMS,
56
57
  debugParams: [