@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,901 @@
1
+ # 宝藏注意力特效实现文档
2
+
3
+ 目标:实现一个类似截图中“宝藏/掉落物周围发光、闪烁、旋转”的游戏特效,用来吸引玩家注意。主实现目标是 **Babylon.js**。本文档也包含 Canvas 2D 原型思路,方便本地 coding agent 先快速验证视觉节奏。
4
+
5
+ ---
6
+
7
+ ## 1. 视觉拆解
8
+
9
+ 这个特效可以拆成 5 层,从下到上绘制:
10
+
11
+ 1. **地面柔光光斑**
12
+ - 一个贴在地面的半透明金黄色圆/椭圆光斑。
13
+ - 使用径向渐变贴图。
14
+ - 缓慢脉冲缩放和透明度变化。
15
+
16
+ 2. **地面旋转星芒**
17
+ - 多片从中心向外发散的金黄色光束。
18
+ - 绕 Y 轴慢速旋转。
19
+ - 每片光束独立做轻微明暗变化,避免机械感。
20
+
21
+ 3. **脉冲光环**
22
+ - 一到两个贴地光环。
23
+ - 缩放 + 透明度脉冲。
24
+ - 用来强调“这里有可交互/可拾取物”。
25
+
26
+ 4. **竖向柔光**
27
+ - 宝藏中心附近有一个竖向的淡黄色 billboard 光柱。
28
+ - 视觉上类似“宝物在发光”。
29
+
30
+ 5. **上浮闪片粒子**
31
+ - 小星星/菱形亮片从宝物附近向上浮起、旋转、淡出。
32
+ - 使用 additive blending。
33
+ - 粒子数量不要太多,否则会抢主体。
34
+
35
+ ---
36
+
37
+ ## 2. Babylon.js 实现原则
38
+
39
+ 建议实现为一个可复用函数:
40
+
41
+ ```js
42
+ const fx = createTreasureAttentionFx(scene, targetMesh, options);
43
+ ```
44
+
45
+ 其中:
46
+
47
+ - `scene`:Babylon.js 场景。
48
+ - `targetMesh`:宝藏 mesh、掉落物 mesh 或任意带 `getAbsolutePosition()` 的对象。
49
+ - `options`:半径、粒子密度、旋转速度、发光强度等参数。
50
+
51
+ 核心设计:
52
+
53
+ - 不依赖外部图片资源。
54
+ - 所有光纹贴图用 `BABYLON.DynamicTexture` 现场绘制。
55
+ - 使用 unlit/additive 材质,避免受到场景灯光影响。
56
+ - 使用 `ParticleSystem` 做上浮闪片。
57
+ - 使用 `GlowLayer` 增强发光,但生产环境建议复用全局 `GlowLayer`。
58
+ - 暴露 `setEnabled()` 和 `dispose()`,方便对象池或掉落物生命周期管理。
59
+
60
+ ---
61
+
62
+ ## 3. 推荐文件结构
63
+
64
+ 建议 coding agent 创建:
65
+
66
+ ```txt
67
+ src/
68
+ effects/
69
+ createTreasureAttentionFx.js
70
+ ```
71
+
72
+ 如果项目是 TypeScript,可以改成:
73
+
74
+ ```txt
75
+ src/
76
+ effects/
77
+ createTreasureAttentionFx.ts
78
+ ```
79
+
80
+ 并给 `options` 补类型即可。
81
+
82
+ ---
83
+
84
+ ## 4. Babylon.js 完整实现代码
85
+
86
+ > 下面代码可以直接作为 `createTreasureAttentionFx.js` 使用。
87
+ > 默认假设 Babylon.js 已经通过全局变量 `BABYLON` 可用。如果项目使用模块化导入,可以把 `BABYLON` 替换成 import 进来的命名空间。
88
+
89
+ ```js
90
+ export function createTreasureAttentionFx(scene, target, options = {}) {
91
+ const B = BABYLON;
92
+
93
+ const o = Object.assign({
94
+ radius: 1.15,
95
+ yOffset: 0.05,
96
+ height: 1.35,
97
+ rays: 9,
98
+ rayWidth: 0.34,
99
+ spinSpeed: 0.85,
100
+ particleRate: 42,
101
+ glowIntensity: 0.7,
102
+ glowLayer: null
103
+ }, options);
104
+
105
+ const root = new B.TransformNode("treasureAttentionFx", scene);
106
+ const raysRoot = new B.TransformNode("treasureAttentionFx_rays", scene);
107
+ raysRoot.parent = root;
108
+
109
+ const textures = [];
110
+ const materials = [];
111
+ const meshes = [];
112
+
113
+ const paleGold = new B.Color3(1.0, 0.94, 0.35);
114
+
115
+ const makeTex = (name, size, draw) => {
116
+ const tex = new B.DynamicTexture(name, { width: size, height: size }, scene, false);
117
+ tex.hasAlpha = true;
118
+ tex.wrapU = tex.wrapV = B.Texture.CLAMP_ADDRESSMODE;
119
+
120
+ const ctx = tex.getContext();
121
+ ctx.clearRect(0, 0, size, size);
122
+ draw(ctx, size);
123
+ tex.update(false);
124
+
125
+ textures.push(tex);
126
+ return tex;
127
+ };
128
+
129
+ const makeAddMat = (name, tex, alpha = 1) => {
130
+ const mat = new B.StandardMaterial(name, scene);
131
+ mat.diffuseTexture = tex;
132
+ mat.emissiveTexture = tex;
133
+ mat.emissiveColor = paleGold;
134
+ mat.diffuseColor = B.Color3.Black();
135
+ mat.alpha = alpha;
136
+ mat.disableLighting = true;
137
+ mat.backFaceCulling = false;
138
+ mat.useAlphaFromDiffuseTexture = true;
139
+ mat.transparencyMode = B.Material.MATERIAL_ALPHABLEND;
140
+ mat.alphaMode = B.Engine.ALPHA_ADD;
141
+
142
+ // 贴地光效容易和地面深度冲突,关闭 depth write 可以减少闪烁。
143
+ // 如果项目有严格的透明物排序策略,可以按项目规则调整。
144
+ mat.disableDepthWrite = true;
145
+
146
+ materials.push(mat);
147
+ return mat;
148
+ };
149
+
150
+ const auraTex = makeTex("treasureFx_auraTex", 256, (ctx, s) => {
151
+ const c = s / 2;
152
+ const g = ctx.createRadialGradient(c, c, 0, c, c, c);
153
+ g.addColorStop(0.00, "rgba(255,255,190,0.85)");
154
+ g.addColorStop(0.25, "rgba(255,235,80,0.45)");
155
+ g.addColorStop(0.55, "rgba(255,210,30,0.16)");
156
+ g.addColorStop(1.00, "rgba(255,210,30,0.00)");
157
+ ctx.fillStyle = g;
158
+ ctx.fillRect(0, 0, s, s);
159
+ });
160
+
161
+ const ringTex = makeTex("treasureFx_ringTex", 256, (ctx, s) => {
162
+ const c = s / 2;
163
+
164
+ ctx.shadowColor = "rgba(255,245,120,0.9)";
165
+ ctx.shadowBlur = s * 0.05;
166
+
167
+ ctx.strokeStyle = "rgba(255,242,80,0.78)";
168
+ ctx.lineWidth = s * 0.035;
169
+ ctx.beginPath();
170
+ ctx.arc(c, c, s * 0.31, 0, Math.PI * 2);
171
+ ctx.stroke();
172
+
173
+ ctx.lineWidth = s * 0.012;
174
+ ctx.strokeStyle = "rgba(255,255,220,0.85)";
175
+ ctx.beginPath();
176
+ ctx.arc(c, c, s * 0.42, 0, Math.PI * 2);
177
+ ctx.stroke();
178
+ });
179
+
180
+ const rayTex = makeTex("treasureFx_rayTex", 256, (ctx, s) => {
181
+ const g = ctx.createLinearGradient(0, 0, s, 0);
182
+ g.addColorStop(0.00, "rgba(255,255,170,0.00)");
183
+ g.addColorStop(0.18, "rgba(255,250,110,0.70)");
184
+ g.addColorStop(0.55, "rgba(255,220,45,0.34)");
185
+ g.addColorStop(1.00, "rgba(255,210,25,0.00)");
186
+
187
+ ctx.fillStyle = g;
188
+ ctx.beginPath();
189
+ ctx.moveTo(0, s * 0.50);
190
+ ctx.quadraticCurveTo(s * 0.45, s * 0.10, s, s * 0.18);
191
+ ctx.lineTo(s, s * 0.82);
192
+ ctx.quadraticCurveTo(s * 0.45, s * 0.90, 0, s * 0.50);
193
+ ctx.closePath();
194
+ ctx.fill();
195
+ });
196
+
197
+ const beamTex = makeTex("treasureFx_beamTex", 256, (ctx, s) => {
198
+ const c = s / 2;
199
+
200
+ const radial = ctx.createRadialGradient(c, c, 0, c, c, c * 0.55);
201
+ radial.addColorStop(0.00, "rgba(255,255,220,0.75)");
202
+ radial.addColorStop(0.28, "rgba(255,245,100,0.35)");
203
+ radial.addColorStop(1.00, "rgba(255,220,40,0.00)");
204
+ ctx.fillStyle = radial;
205
+ ctx.fillRect(0, 0, s, s);
206
+
207
+ const vertical = ctx.createLinearGradient(0, 0, 0, s);
208
+ vertical.addColorStop(0.00, "rgba(255,255,220,0.00)");
209
+ vertical.addColorStop(0.42, "rgba(255,245,90,0.42)");
210
+ vertical.addColorStop(0.62, "rgba(255,245,90,0.42)");
211
+ vertical.addColorStop(1.00, "rgba(255,220,30,0.00)");
212
+ ctx.fillStyle = vertical;
213
+ ctx.fillRect(s * 0.43, 0, s * 0.14, s);
214
+ });
215
+
216
+ const sparkleTex = makeTex("treasureFx_sparkleTex", 128, (ctx, s) => {
217
+ const c = s / 2;
218
+
219
+ ctx.save();
220
+ ctx.translate(c, c);
221
+ ctx.shadowColor = "rgba(255,245,120,1)";
222
+ ctx.shadowBlur = s * 0.12;
223
+ ctx.fillStyle = "rgba(255,255,210,0.95)";
224
+
225
+ ctx.beginPath();
226
+ for (let i = 0; i < 8; i++) {
227
+ const a = i * Math.PI / 4;
228
+ const r = i % 2 === 0 ? s * 0.42 : s * 0.12;
229
+ const x = Math.cos(a) * r;
230
+ const y = Math.sin(a) * r;
231
+ i ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
232
+ }
233
+ ctx.closePath();
234
+ ctx.fill();
235
+
236
+ ctx.restore();
237
+ });
238
+
239
+ const auraMat = makeAddMat("treasureFx_auraMat", auraTex, 0.85);
240
+ const ringMat = makeAddMat("treasureFx_ringMat", ringTex, 0.95);
241
+ const rayMat = makeAddMat("treasureFx_rayMat", rayTex, 0.9);
242
+ const beamMat = makeAddMat("treasureFx_beamMat", beamTex, 0.72);
243
+
244
+ const aura = B.MeshBuilder.CreatePlane("treasureFx_aura", { size: o.radius * 2.55 }, scene);
245
+ aura.rotation.x = Math.PI / 2;
246
+ aura.position.y = 0.025;
247
+ aura.material = auraMat;
248
+ aura.parent = root;
249
+ aura.renderingGroupId = 2;
250
+ meshes.push(aura);
251
+
252
+ const ring = B.MeshBuilder.CreatePlane("treasureFx_ring", { size: o.radius * 2.15 }, scene);
253
+ ring.rotation.x = Math.PI / 2;
254
+ ring.position.y = 0.04;
255
+ ring.material = ringMat;
256
+ ring.parent = root;
257
+ ring.renderingGroupId = 2;
258
+ meshes.push(ring);
259
+
260
+ const beam = B.MeshBuilder.CreatePlane("treasureFx_beam", {
261
+ width: o.radius * 1.05,
262
+ height: o.height
263
+ }, scene);
264
+ beam.position.y = o.height * 0.52;
265
+ beam.billboardMode = B.Mesh.BILLBOARDMODE_Y;
266
+ beam.material = beamMat;
267
+ beam.parent = root;
268
+ beam.renderingGroupId = 2;
269
+ meshes.push(beam);
270
+
271
+ const createRayMesh = (name, length, width) => {
272
+ const mesh = new B.Mesh(name, scene);
273
+
274
+ const positions = [
275
+ 0, 0, -width * 0.50,
276
+ length, 0, -width * 0.12,
277
+ length, 0, width * 0.12,
278
+ 0, 0, width * 0.50
279
+ ];
280
+
281
+ const indices = [0, 1, 2, 0, 2, 3];
282
+ const normals = [];
283
+ B.VertexData.ComputeNormals(positions, indices, normals);
284
+
285
+ const vd = new B.VertexData();
286
+ vd.positions = positions;
287
+ vd.indices = indices;
288
+ vd.normals = normals;
289
+ vd.uvs = [0, 0, 1, 0, 1, 1, 0, 1];
290
+ vd.applyToMesh(mesh, true);
291
+
292
+ return mesh;
293
+ };
294
+
295
+ const rays = [];
296
+ for (let i = 0; i < o.rays; i++) {
297
+ const angle = i / o.rays * Math.PI * 2;
298
+ const length = o.radius * (0.95 + (i % 3) * 0.13);
299
+ const width = o.rayWidth * (0.8 + (i % 2) * 0.22);
300
+
301
+ const ray = createRayMesh(`treasureFx_ray_${i}`, length, width);
302
+ ray.parent = raysRoot;
303
+ ray.rotation.y = angle;
304
+ ray.position.y = 0.055 + i * 0.001;
305
+ ray.material = rayMat;
306
+ ray.renderingGroupId = 2;
307
+
308
+ rays.push(ray);
309
+ meshes.push(ray);
310
+ }
311
+
312
+ const emitter = new B.Mesh("treasureFx_particleEmitter", scene);
313
+ emitter.isVisible = false;
314
+ emitter.parent = root;
315
+ meshes.push(emitter);
316
+
317
+ const ps = new B.ParticleSystem("treasureFx_sparkles", 160, scene);
318
+ ps.particleTexture = sparkleTex;
319
+ ps.emitter = emitter;
320
+
321
+ ps.minEmitBox = new B.Vector3(-o.radius * 0.45, 0.15, -o.radius * 0.45);
322
+ ps.maxEmitBox = new B.Vector3( o.radius * 0.45, 0.55, o.radius * 0.45);
323
+
324
+ ps.color1 = new B.Color4(1, 0.92, 0.24, 0.95);
325
+ ps.color2 = new B.Color4(1, 1, 0.72, 0.75);
326
+ ps.colorDead = new B.Color4(1, 0.75, 0.15, 0);
327
+
328
+ ps.minSize = 0.045;
329
+ ps.maxSize = 0.13;
330
+ ps.minLifeTime = 0.45;
331
+ ps.maxLifeTime = 0.95;
332
+ ps.emitRate = o.particleRate;
333
+ ps.blendMode = B.ParticleSystem.BLENDMODE_ADD;
334
+
335
+ ps.gravity = new B.Vector3(0, -0.04, 0);
336
+ ps.direction1 = new B.Vector3(-0.12, 0.55, -0.12);
337
+ ps.direction2 = new B.Vector3( 0.12, 0.95, 0.12);
338
+ ps.minAngularSpeed = -4;
339
+ ps.maxAngularSpeed = 4;
340
+ ps.minEmitPower = 0.18;
341
+ ps.maxEmitPower = 0.42;
342
+ ps.updateSpeed = 0.016;
343
+
344
+ ps.start();
345
+
346
+ const ownsGlow = !o.glowLayer;
347
+ const glow = o.glowLayer || new B.GlowLayer("treasureFx_glowLayer", scene, { blurKernelSize: 32 });
348
+
349
+ if (ownsGlow) {
350
+ glow.intensity = o.glowIntensity;
351
+ }
352
+
353
+ if (glow.addIncludedOnlyMesh) {
354
+ meshes.filter(m => m.material).forEach(m => glow.addIncludedOnlyMesh(m));
355
+ }
356
+
357
+ const syncRoot = () => {
358
+ if (!target) {
359
+ root.position.set(0, o.yOffset, 0);
360
+ return;
361
+ }
362
+
363
+ if (typeof target.getAbsolutePosition === "function") {
364
+ const p = target.getAbsolutePosition();
365
+ root.position.set(p.x, p.y + o.yOffset, p.z);
366
+ return;
367
+ }
368
+
369
+ root.position.set(target.x || 0, (target.y || 0) + o.yOffset, target.z || 0);
370
+ };
371
+
372
+ let time = 0;
373
+
374
+ const observer = scene.onBeforeRenderObservable.add(() => {
375
+ const dt = scene.getEngine().getDeltaTime() * 0.001;
376
+ time += dt;
377
+ syncRoot();
378
+
379
+ const pulse = 0.5 + 0.5 * Math.sin(time * 5.2);
380
+
381
+ aura.scaling.setAll(0.96 + pulse * 0.11);
382
+ aura.visibility = 0.72 + pulse * 0.24;
383
+
384
+ ring.rotation.z += dt * 0.65;
385
+ ring.scaling.setAll(0.94 + pulse * 0.08);
386
+ ring.visibility = 0.55 + pulse * 0.36;
387
+
388
+ beam.scaling.x = 0.85 + pulse * 0.28;
389
+ beam.visibility = 0.35 + pulse * 0.28;
390
+
391
+ raysRoot.rotation.y += dt * o.spinSpeed;
392
+
393
+ for (let i = 0; i < rays.length; i++) {
394
+ const q = 0.5 + 0.5 * Math.sin(time * 4.4 + i * 0.9);
395
+ rays[i].visibility = 0.42 + q * 0.42;
396
+ rays[i].scaling.x = 0.92 + q * 0.18;
397
+ rays[i].scaling.z = 0.82 + q * 0.35;
398
+ }
399
+
400
+ // 不建议在复用全局 GlowLayer 时反复改全局 intensity,
401
+ // 因为会影响场景里其他发光对象。
402
+ if (ownsGlow) {
403
+ glow.intensity = o.glowIntensity * (0.75 + pulse * 0.35);
404
+ }
405
+ });
406
+
407
+ syncRoot();
408
+
409
+ return {
410
+ root,
411
+ particleSystem: ps,
412
+ meshes,
413
+ materials,
414
+ textures,
415
+
416
+ setEnabled(enabled) {
417
+ root.setEnabled(enabled);
418
+ if (enabled) {
419
+ ps.start();
420
+ } else {
421
+ ps.stop();
422
+ }
423
+ },
424
+
425
+ setTarget(nextTarget) {
426
+ target = nextTarget;
427
+ syncRoot();
428
+ },
429
+
430
+ dispose() {
431
+ scene.onBeforeRenderObservable.remove(observer);
432
+
433
+ ps.dispose();
434
+
435
+ meshes.forEach(m => {
436
+ if (m && !m.isDisposed()) {
437
+ m.dispose();
438
+ }
439
+ });
440
+
441
+ materials.forEach(m => {
442
+ if (m) {
443
+ m.dispose();
444
+ }
445
+ });
446
+
447
+ textures.forEach(t => {
448
+ if (t) {
449
+ t.dispose();
450
+ }
451
+ });
452
+
453
+ root.dispose();
454
+
455
+ if (ownsGlow) {
456
+ glow.dispose();
457
+ }
458
+ }
459
+ };
460
+ }
461
+ ```
462
+
463
+ ---
464
+
465
+ ## 5. 使用示例
466
+
467
+ ### 5.1 普通用法
468
+
469
+ ```js
470
+ import { createTreasureAttentionFx } from "./effects/createTreasureAttentionFx.js";
471
+
472
+ const treasure = scene.getMeshByName("TreasureChest");
473
+
474
+ const fx = createTreasureAttentionFx(scene, treasure, {
475
+ radius: 1.05,
476
+ yOffset: 0.04,
477
+ height: 1.25,
478
+ particleRate: 55,
479
+ glowIntensity: 0.8
480
+ });
481
+ ```
482
+
483
+ ### 5.2 复用全局 GlowLayer
484
+
485
+ 如果一个场景里有多个宝藏/掉落物,不要每个特效都创建一个 `GlowLayer`。推荐全局复用:
486
+
487
+ ```js
488
+ const sharedGlow = new BABYLON.GlowLayer("sharedGlow", scene, {
489
+ blurKernelSize: 32
490
+ });
491
+
492
+ sharedGlow.intensity = 0.65;
493
+
494
+ const fx1 = createTreasureAttentionFx(scene, treasureA, {
495
+ glowLayer: sharedGlow,
496
+ radius: 1.1
497
+ });
498
+
499
+ const fx2 = createTreasureAttentionFx(scene, treasureB, {
500
+ glowLayer: sharedGlow,
501
+ radius: 0.9,
502
+ particleRate: 30
503
+ });
504
+ ```
505
+
506
+ ### 5.3 跟随掉落物对象池
507
+
508
+ ```js
509
+ const fx = createTreasureAttentionFx(scene, dropMesh, {
510
+ radius: 0.9,
511
+ particleRate: 35
512
+ });
513
+
514
+ dropMesh.onDisposeObservable.add(() => {
515
+ fx.dispose();
516
+ });
517
+ ```
518
+
519
+ 如果掉落物对象池复用 mesh,可以保留 fx,切换 target:
520
+
521
+ ```js
522
+ fx.setTarget(newDropMesh);
523
+ fx.setEnabled(true);
524
+ ```
525
+
526
+ ---
527
+
528
+ ## 6. 参数说明
529
+
530
+ | 参数 | 默认值 | 说明 |
531
+ |---|---:|---|
532
+ | `radius` | `1.15` | 地面光斑和星芒半径。宝物越大,这个值越大。 |
533
+ | `yOffset` | `0.05` | 特效根节点相对目标位置的 Y 偏移。用于避免地面 z-fighting。 |
534
+ | `height` | `1.35` | 竖向柔光高度。 |
535
+ | `rays` | `9` | 星芒光束数量。建议 7~12。 |
536
+ | `rayWidth` | `0.34` | 单片星芒宽度。 |
537
+ | `spinSpeed` | `0.85` | 星芒旋转速度,单位大致是 rad/s。 |
538
+ | `particleRate` | `42` | 每秒发射的闪片数量。移动端建议 20~35。 |
539
+ | `glowIntensity` | `0.7` | 自建 GlowLayer 时使用的强度。复用全局 GlowLayer 时不主动改全局强度。 |
540
+ | `glowLayer` | `null` | 可传入项目里的共享 GlowLayer。 |
541
+
542
+ ---
543
+
544
+ ## 7. 调参建议
545
+
546
+ ### 更像“稀有宝藏”
547
+
548
+ ```js
549
+ createTreasureAttentionFx(scene, treasure, {
550
+ radius: 1.35,
551
+ height: 1.6,
552
+ rays: 11,
553
+ particleRate: 70,
554
+ spinSpeed: 1.1,
555
+ glowIntensity: 0.95
556
+ });
557
+ ```
558
+
559
+ ### 更像“普通可拾取物”
560
+
561
+ ```js
562
+ createTreasureAttentionFx(scene, drop, {
563
+ radius: 0.75,
564
+ height: 0.8,
565
+ rays: 7,
566
+ particleRate: 20,
567
+ spinSpeed: 0.55,
568
+ glowIntensity: 0.45
569
+ });
570
+ ```
571
+
572
+ ### 更像“任务目标/交互点”
573
+
574
+ ```js
575
+ createTreasureAttentionFx(scene, interactable, {
576
+ radius: 1.0,
577
+ height: 1.1,
578
+ particleRate: 15,
579
+ spinSpeed: 0.35
580
+ });
581
+ ```
582
+
583
+ ---
584
+
585
+ ## 8. 需要 coding agent 注意的坑
586
+
587
+ ### 8.1 地面 z-fighting
588
+
589
+ 贴地 plane 如果和地面完全重合,可能闪烁。
590
+
591
+ 解决方式:
592
+
593
+ - `aura.position.y = 0.025`
594
+ - `ring.position.y = 0.04`
595
+ - `ray.position.y = 0.055 + i * 0.001`
596
+ - 材质设置 `disableDepthWrite = true`
597
+ - 必要时把 `renderingGroupId` 设置到透明特效组。
598
+
599
+ ### 8.2 透明排序
600
+
601
+ 多个透明 mesh 相互叠加时可能排序异常。
602
+
603
+ 建议:
604
+
605
+ - 所有光效材质使用 additive blending。
606
+ - 特效 mesh 的 `renderingGroupId` 保持一致。
607
+ - 复杂场景中可以把地面光、竖向光、粒子拆成不同 rendering group。
608
+
609
+ ### 8.3 GlowLayer 复用
610
+
611
+ `GlowLayer.intensity` 是全局属性,会影响这个 GlowLayer 下所有发光对象。
612
+
613
+ 建议:
614
+
615
+ - 场景只有一个宝藏特效时,可以让函数内部创建 GlowLayer。
616
+ - 场景中有多个发光对象时,传入共享 GlowLayer。
617
+ - 传入共享 GlowLayer 时,不要在每个特效实例里每帧修改 `glow.intensity`。
618
+
619
+ ### 8.4 顶视角相机
620
+
621
+ 如果相机几乎纯俯视,单个竖向 billboard plane 可能不明显。
622
+
623
+ 可选优化:
624
+
625
+ - 加两片互相垂直的竖向 beam plane。
626
+ - 或者只保留地面光斑、星芒、粒子,不使用竖向 beam。
627
+
628
+ ### 8.5 移动端性能
629
+
630
+ 如果场景中同时存在很多宝藏特效:
631
+
632
+ - 粒子池数量从 `160` 降到 `80`。
633
+ - `particleRate` 降到 `15~30`。
634
+ - `rays` 降到 `6~8`。
635
+ - 复用全局 `GlowLayer`。
636
+ - 尽量只给屏幕内或玩家附近的物体开启特效。
637
+
638
+ ---
639
+
640
+ ## 9. Canvas 2D 原型做法
641
+
642
+ Canvas 版不直接用于 Babylon 场景,但很适合快速调视觉节奏。绘制顺序和 Babylon 版一致:
643
+
644
+ 1. 画地面椭圆径向渐变。
645
+ 2. 画旋转光束。
646
+ 3. 画竖向柔光。
647
+ 4. 画脉冲光环。
648
+ 5. 更新并绘制上浮闪片。
649
+
650
+ 核心伪代码:
651
+
652
+ ```js
653
+ class TreasureAttentionFx2D {
654
+ constructor() {
655
+ this.particles = [];
656
+ this.radius = 90;
657
+ this.particleRate = 48;
658
+ this.spinSpeed = 0.85;
659
+ }
660
+
661
+ update(dt, x, y) {
662
+ this.emit(dt, x, y);
663
+
664
+ for (const p of this.particles) {
665
+ p.age += dt;
666
+ p.x += p.vx * dt;
667
+ p.y += p.vy * dt;
668
+ p.rot += p.spin * dt;
669
+ }
670
+
671
+ this.particles = this.particles.filter(p => p.age < p.life);
672
+ }
673
+
674
+ emit(dt, x, y) {
675
+ const count = this.particleRate * dt;
676
+ this._carry = (this._carry || 0) + count;
677
+
678
+ while (this._carry >= 1) {
679
+ this._carry -= 1;
680
+
681
+ const a = Math.random() * Math.PI * 2;
682
+ const r = Math.random() * this.radius * 0.35;
683
+
684
+ this.particles.push({
685
+ x: x + Math.cos(a) * r,
686
+ y: y + Math.sin(a) * r * 0.35 - 12,
687
+ vx: (Math.random() - 0.5) * 18,
688
+ vy: -35 - Math.random() * 45,
689
+ age: 0,
690
+ life: 0.55 + Math.random() * 0.7,
691
+ size: 4 + Math.random() * 7,
692
+ rot: Math.random() * Math.PI * 2,
693
+ spin: -5 + Math.random() * 10
694
+ });
695
+ }
696
+ }
697
+
698
+ draw(ctx, x, y, t) {
699
+ const pulse = 0.5 + 0.5 * Math.sin(t * 5.2);
700
+
701
+ this.drawGroundAura(ctx, x, y, pulse);
702
+ this.drawRays(ctx, x, y - 8, t, pulse);
703
+ this.drawVerticalGlow(ctx, x, y - 32, pulse);
704
+ this.drawPulseRing(ctx, x, y, pulse);
705
+ this.drawParticles(ctx);
706
+ }
707
+
708
+ drawGroundAura(ctx, x, y, pulse) {
709
+ const r = this.radius * (0.95 + pulse * 0.12);
710
+
711
+ ctx.save();
712
+ ctx.translate(x, y);
713
+ ctx.scale(1, 0.42);
714
+
715
+ const g = ctx.createRadialGradient(0, 0, 0, 0, 0, r);
716
+ g.addColorStop(0.00, "rgba(255,255,180,0.78)");
717
+ g.addColorStop(0.35, "rgba(255,225,55,0.32)");
718
+ g.addColorStop(1.00, "rgba(255,210,20,0)");
719
+
720
+ ctx.fillStyle = g;
721
+ ctx.beginPath();
722
+ ctx.arc(0, 0, r, 0, Math.PI * 2);
723
+ ctx.fill();
724
+
725
+ ctx.restore();
726
+ }
727
+
728
+ drawRays(ctx, x, y, t, pulse) {
729
+ const rays = 9;
730
+ const base = t * this.spinSpeed;
731
+
732
+ ctx.save();
733
+ ctx.translate(x, y);
734
+ ctx.globalCompositeOperation = "lighter";
735
+
736
+ for (let i = 0; i < rays; i++) {
737
+ const a = base + i / rays * Math.PI * 2;
738
+ const len = this.radius * (0.75 + (i % 3) * 0.09);
739
+ const width = 10 + (i % 2) * 7;
740
+ const alpha = 0.18 + 0.18 * Math.sin(t * 4.4 + i * 0.9) + pulse * 0.08;
741
+
742
+ ctx.save();
743
+ ctx.rotate(a);
744
+ const g = ctx.createLinearGradient(0, 0, len, 0);
745
+ g.addColorStop(0.00, `rgba(255,255,180,${alpha})`);
746
+ g.addColorStop(0.38, `rgba(255,230,45,${alpha * 0.8})`);
747
+ g.addColorStop(1.00, "rgba(255,210,20,0)");
748
+
749
+ ctx.fillStyle = g;
750
+ ctx.beginPath();
751
+ ctx.moveTo(0, -width);
752
+ ctx.quadraticCurveTo(len * 0.45, -width * 1.4, len, -width * 0.25);
753
+ ctx.lineTo(len, width * 0.25);
754
+ ctx.quadraticCurveTo(len * 0.45, width * 1.4, 0, width);
755
+ ctx.closePath();
756
+ ctx.fill();
757
+ ctx.restore();
758
+ }
759
+
760
+ ctx.restore();
761
+ }
762
+
763
+ drawVerticalGlow(ctx, x, y, pulse) {
764
+ const h = this.radius * 1.25;
765
+ const w = this.radius * (0.65 + pulse * 0.16);
766
+
767
+ ctx.save();
768
+ ctx.translate(x, y - h * 0.35);
769
+ ctx.globalCompositeOperation = "lighter";
770
+
771
+ const g = ctx.createRadialGradient(0, 0, 0, 0, 0, h);
772
+ g.addColorStop(0.00, "rgba(255,255,220,0.28)");
773
+ g.addColorStop(0.32, "rgba(255,238,80,0.15)");
774
+ g.addColorStop(1.00, "rgba(255,220,20,0)");
775
+
776
+ ctx.scale(w / h, 1);
777
+ ctx.fillStyle = g;
778
+ ctx.beginPath();
779
+ ctx.arc(0, 0, h, 0, Math.PI * 2);
780
+ ctx.fill();
781
+
782
+ ctx.restore();
783
+ }
784
+
785
+ drawPulseRing(ctx, x, y, pulse) {
786
+ ctx.save();
787
+ ctx.translate(x, y);
788
+ ctx.scale(1, 0.42);
789
+ ctx.globalCompositeOperation = "lighter";
790
+
791
+ for (let i = 0; i < 2; i++) {
792
+ const k = (pulse + i * 0.5) % 1;
793
+ const r = this.radius * (0.42 + k * 0.36);
794
+ const a = 0.45 * (1 - k);
795
+
796
+ ctx.strokeStyle = `rgba(255,245,95,${a})`;
797
+ ctx.lineWidth = 3;
798
+ ctx.beginPath();
799
+ ctx.arc(0, 0, r, 0, Math.PI * 2);
800
+ ctx.stroke();
801
+ }
802
+
803
+ ctx.restore();
804
+ }
805
+
806
+ drawParticles(ctx) {
807
+ ctx.save();
808
+ ctx.globalCompositeOperation = "lighter";
809
+
810
+ for (const p of this.particles) {
811
+ const k = p.age / p.life;
812
+ const alpha = Math.sin((1 - k) * Math.PI) * 0.95;
813
+
814
+ ctx.save();
815
+ ctx.translate(p.x, p.y);
816
+ ctx.rotate(p.rot);
817
+ ctx.scale(1, 0.72);
818
+
819
+ ctx.fillStyle = `rgba(255,255,210,${alpha})`;
820
+ ctx.shadowColor = "rgba(255,235,70,0.95)";
821
+ ctx.shadowBlur = p.size * 1.4;
822
+
823
+ ctx.beginPath();
824
+ ctx.moveTo(0, -p.size);
825
+ ctx.lineTo(p.size * 0.28, -p.size * 0.28);
826
+ ctx.lineTo(p.size, 0);
827
+ ctx.lineTo(p.size * 0.28, p.size * 0.28);
828
+ ctx.lineTo(0, p.size);
829
+ ctx.lineTo(-p.size * 0.28, p.size * 0.28);
830
+ ctx.lineTo(-p.size, 0);
831
+ ctx.lineTo(-p.size * 0.28, -p.size * 0.28);
832
+ ctx.closePath();
833
+ ctx.fill();
834
+
835
+ ctx.restore();
836
+ }
837
+
838
+ ctx.restore();
839
+ }
840
+ }
841
+ ```
842
+
843
+ Canvas 主循环:
844
+
845
+ ```js
846
+ const canvas = document.querySelector("canvas");
847
+ const ctx = canvas.getContext("2d");
848
+ const fx = new TreasureAttentionFx2D();
849
+
850
+ let last = performance.now();
851
+ let time = 0;
852
+
853
+ function tick(now) {
854
+ const dt = Math.min(0.033, (now - last) / 1000);
855
+ last = now;
856
+ time += dt;
857
+
858
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
859
+
860
+ const x = canvas.width * 0.5;
861
+ const y = canvas.height * 0.62;
862
+
863
+ fx.update(dt, x, y);
864
+ fx.draw(ctx, x, y, time);
865
+
866
+ requestAnimationFrame(tick);
867
+ }
868
+
869
+ requestAnimationFrame(tick);
870
+ ```
871
+
872
+ ---
873
+
874
+ ## 10. 验收标准
875
+
876
+ coding agent 实现后,用下面标准检查:
877
+
878
+ - 宝藏周围有明显金黄色柔光。
879
+ - 地面有慢速旋转的星芒,能吸引注意但不刺眼。
880
+ - 光环有脉冲感。
881
+ - 有少量小闪片上浮并淡出。
882
+ - 特效能跟随目标 mesh 移动。
883
+ - `setEnabled(false)` 后粒子停止,root 隐藏。
884
+ - `dispose()` 后没有残留 mesh、texture、material、observer、particle system。
885
+ - 多个宝藏同时存在时帧率稳定。
886
+ - 共享 `GlowLayer` 时不会异常改变其他发光物体强度。
887
+ - 从斜俯视/isometric 相机看,效果仍然清楚。
888
+
889
+ ---
890
+
891
+ ## 11. 建议的实现任务拆分
892
+
893
+ 给本地 coding agent 的任务可以拆成:
894
+
895
+ 1. 新建 `src/effects/createTreasureAttentionFx.js`。
896
+ 2. 粘贴 Babylon.js 实现代码。
897
+ 3. 在宝藏/掉落物生成逻辑中调用 `createTreasureAttentionFx(scene, treasureMesh, options)`。
898
+ 4. 项目已有全局 `GlowLayer` 时,作为 `options.glowLayer` 传入。
899
+ 5. 在宝藏销毁、拾取、隐藏时调用 `fx.dispose()` 或 `fx.setEnabled(false)`。
900
+ 6. 根据实际模型尺寸调整 `radius`、`yOffset`、`height`。
901
+ 7. 在目标平台上压测多个实例,必要时降低 `particleRate`、`rays` 和粒子池数量。