@fps-games/vfx 0.3.7 → 0.3.9

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.
package/CHANGELOG.md CHANGED
@@ -12,6 +12,22 @@
12
12
 
13
13
  <!-- release.mjs 在此行下方插入新版本区块 -->
14
14
 
15
+ ## v0.3.9 — 2026-07-03
16
+
17
+ ### 改动
18
+ - feat: add magnet suction effect
19
+
20
+ ### 特效
21
+ - 新增:magnet-suction
22
+
23
+ ## v0.3.8 — 2026-07-02
24
+
25
+ ### 改动
26
+ - fix(building-upgrade): treat offset scale as footprint scale
27
+
28
+ ### 特效
29
+ - 无新增 / 删除(仍 37 个)。
30
+
15
31
  ## v0.3.7 — 2026-07-02
16
32
 
17
33
  ### 改动
@@ -38,6 +38,7 @@ const buildingUpgradeEffectPackage = {
38
38
  manifest,
39
39
  defaultParams: DEFAULT_PARAMS,
40
40
  debugAnchor: "playerRight",
41
+ applyRootScale: false,
41
42
  debugParams: [
42
43
  { key: "lifetime", label: "生命周期", kind: "lifetime", min: 0.2, max: 6, step: 0.01 },
43
44
  { key: "color", label: "金光颜色", kind: "color" },
@@ -56,13 +57,13 @@ const buildingUpgradeEffectPackage = {
56
57
  ],
57
58
  create({ scene, root, position, params, scale }) {
58
59
  root.position.copyFrom(position);
59
- const visualScale = normalizeBuildingUpgradeScale(scale);
60
- const centerStrands = createStrandSystem(scene, root, params, visualScale, "center");
61
- const outlineStrands = createStrandSystem(scene, root, params, visualScale, "outline");
62
- const glow = createOutlineGlow(scene, root, params, visualScale);
60
+ const footprintScale = normalizeBuildingUpgradeScale(scale);
61
+ const centerStrands = createStrandSystem(scene, root, params, footprintScale, "center");
62
+ const outlineStrands = createStrandSystem(scene, root, params, footprintScale, "outline");
63
+ const glow = createOutlineGlow(scene, root, params, footprintScale);
63
64
  const disposeTimer = window.setTimeout(
64
65
  () => disposeAll(),
65
- Math.max(0.05, params.lifetime + getMaxStrandHeight(visualScale) / Math.max(0.1, params.riseSpeed) * 0.18 + 0.45) * 1e3
66
+ Math.max(0.05, params.lifetime + getMaxStrandHeight() / Math.max(0.1, params.riseSpeed) * 0.18 + 0.45) * 1e3
66
67
  );
67
68
  let disposed = false;
68
69
  centerStrands.start();
@@ -85,12 +86,12 @@ const buildingUpgradeEffectPackage = {
85
86
  }
86
87
  }
87
88
  };
88
- function createStrandSystem(scene, root, params, visualScale, mode) {
89
+ function createStrandSystem(scene, root, params, footprintScale, mode) {
89
90
  const count = Math.max(0, Math.round(mode === "center" ? params.centerCount : params.outlineCount));
90
91
  const system = new ParticleSystem(`building-upgrade.${mode}-strands`, Math.max(1, count), scene);
91
92
  const color = params.color;
92
- const height = mode === "center" ? getCenterHeight(visualScale) : getOutlineHeight(visualScale);
93
- const width = params.strandWidth * visualScale.horizontal * (mode === "center" ? 1.35 : 0.92);
93
+ const height = mode === "center" ? getCenterHeight() : getOutlineHeight();
94
+ const width = params.strandWidth * (mode === "center" ? 1.35 : 0.92);
94
95
  configureVfxParticleSystem(system);
95
96
  system.particleTexture = getGoldStrandTexture(scene);
96
97
  system.emitter = root;
@@ -126,7 +127,7 @@ function createStrandSystem(scene, root, params, visualScale, mode) {
126
127
  system.startPositionFunction = (worldMatrix, positionToUpdate, particle, isLocal) => {
127
128
  const index = particle.id + positionIndex;
128
129
  positionIndex += 1;
129
- const local = mode === "center" ? sampleCenterPosition(index, params) : sampleOutlinePosition(index, params);
130
+ const local = mode === "center" ? sampleCenterPosition(index, params, footprintScale) : sampleOutlinePosition(index, params, footprintScale);
130
131
  if (isLocal) positionToUpdate.copyFrom(local);
131
132
  else Vector3.TransformCoordinatesToRef(local, worldMatrix, positionToUpdate);
132
133
  };
@@ -144,11 +145,11 @@ function createStrandSystem(scene, root, params, visualScale, mode) {
144
145
  };
145
146
  return system;
146
147
  }
147
- function createOutlineGlow(scene, root, params, visualScale) {
148
+ function createOutlineGlow(scene, root, params, footprintScale) {
148
149
  const count = Math.max(1, Math.round(params.outlineCount * 0.82));
149
150
  const system = new ParticleSystem("building-upgrade.outline-glow-rays", count, scene);
150
- const height = getOutlineHeight(visualScale) * 1.06;
151
- const glowWidth = Math.max(0.04, BASE_GLOW_THICKNESS * visualScale.horizontal);
151
+ const height = getOutlineHeight() * 1.06;
152
+ const glowWidth = Math.max(0.04, BASE_GLOW_THICKNESS);
152
153
  const alpha = params.glowOpacity * params.opacity;
153
154
  configureVfxParticleSystem(system);
154
155
  system.particleTexture = getOutlineGlowTexture(scene);
@@ -185,7 +186,7 @@ function createOutlineGlow(scene, root, params, visualScale) {
185
186
  system.startPositionFunction = (worldMatrix, positionToUpdate, particle, isLocal) => {
186
187
  const index = particle.id + positionIndex + 31;
187
188
  positionIndex += 1;
188
- const local = sampleOutlinePosition(index, params);
189
+ const local = sampleOutlinePosition(index, params, footprintScale);
189
190
  if (isLocal) positionToUpdate.copyFrom(local);
190
191
  else Vector3.TransformCoordinatesToRef(local, worldMatrix, positionToUpdate);
191
192
  };
@@ -203,13 +204,13 @@ function createOutlineGlow(scene, root, params, visualScale) {
203
204
  };
204
205
  return system;
205
206
  }
206
- function sampleCenterPosition(index, params) {
207
+ function sampleCenterPosition(index, params, footprintScale) {
207
208
  const shape = getFootprintShape(params);
208
- if (shape === "circle") return sampleCircleCenterPosition(index, params);
209
+ if (shape === "circle") return sampleCircleCenterPosition(index, params, footprintScale);
209
210
  const angle = sequence01(index, 0) * Math.PI * 2;
210
211
  const radius = Math.sqrt(0.16 + sequence01(index, 1) * 0.84);
211
- const xRadius = getFootprintX() * 0.5 * params.centerSpread;
212
- const zRadius = getFootprintZ() * 0.5 * params.centerSpread;
212
+ const xRadius = getFootprintX(footprintScale) * 0.5 * params.centerSpread;
213
+ const zRadius = getFootprintZ(footprintScale) * 0.5 * params.centerSpread;
213
214
  const jitterX = (sequence01(index, 7) - 0.5) * xRadius * 0.18;
214
215
  const jitterZ = (sequence01(index, 8) - 0.5) * zRadius * 0.18;
215
216
  const x = Math.cos(angle) * radius * xRadius + jitterX;
@@ -217,23 +218,24 @@ function sampleCenterPosition(index, params) {
217
218
  const y = sequence01(index, 2) * 0.12;
218
219
  return new Vector3(x, y, z);
219
220
  }
220
- function sampleOutlinePosition(index, params) {
221
+ function sampleOutlinePosition(index, params, footprintScale) {
221
222
  const shape = getFootprintShape(params);
222
- if (shape === "circle") return sampleCircleOutlinePosition(index, params);
223
- return sampleRectOutlinePosition(index, getFootprintX(), getFootprintZ(), params.outlineThickness);
223
+ if (shape === "circle") return sampleCircleOutlinePosition(index, params, footprintScale);
224
+ return sampleRectOutlinePosition(index, getFootprintX(footprintScale), getFootprintZ(footprintScale), params.outlineThickness);
224
225
  }
225
- function sampleCircleCenterPosition(index, params) {
226
+ function sampleCircleCenterPosition(index, params, footprintScale) {
226
227
  const angle = sequence01(index, 0) * Math.PI * 2;
227
- const radius = Math.sqrt(sequence01(index, 1)) * BASE_CIRCLE_RADIUS * params.centerSpread;
228
- const jitterRadius = (sequence01(index, 7) - 0.5) * BASE_CIRCLE_RADIUS * 0.08;
228
+ const baseRadius = BASE_CIRCLE_RADIUS * footprintScale.horizontal;
229
+ const radius = Math.sqrt(sequence01(index, 1)) * baseRadius * params.centerSpread;
230
+ const jitterRadius = (sequence01(index, 7) - 0.5) * baseRadius * 0.08;
229
231
  const r = Math.max(0, radius + jitterRadius);
230
232
  const y = sequence01(index, 2) * 0.12;
231
233
  return new Vector3(Math.cos(angle) * r, y, Math.sin(angle) * r);
232
234
  }
233
- function sampleCircleOutlinePosition(index, params) {
235
+ function sampleCircleOutlinePosition(index, params, footprintScale) {
234
236
  const angle = sequence01(index, 0) * Math.PI * 2;
235
237
  const jitter = (sequence01(index, 1) - 0.5) * params.outlineThickness;
236
- const radius = Math.max(1e-3, BASE_CIRCLE_RADIUS + jitter);
238
+ const radius = Math.max(1e-3, BASE_CIRCLE_RADIUS * footprintScale.horizontal + jitter);
237
239
  const y = sequence01(index, 2) * 0.16;
238
240
  return new Vector3(Math.cos(angle) * radius, y, Math.sin(angle) * radius);
239
241
  }
@@ -251,28 +253,26 @@ function getFootprintShape(params) {
251
253
  if (params.footprintShape === "circle") return params.footprintShape;
252
254
  return "rectangle";
253
255
  }
254
- function getFootprintX() {
255
- return BASE_FOOTPRINT_X;
256
+ function getFootprintX(footprintScale) {
257
+ return BASE_FOOTPRINT_X * footprintScale.x;
256
258
  }
257
- function getFootprintZ() {
258
- return BASE_FOOTPRINT_Z;
259
+ function getFootprintZ(footprintScale) {
260
+ return BASE_FOOTPRINT_Z * footprintScale.z;
259
261
  }
260
- function getCenterHeight(visualScale) {
261
- return BASE_CENTER_HEIGHT * visualScale.y;
262
+ function getCenterHeight() {
263
+ return BASE_CENTER_HEIGHT;
262
264
  }
263
- function getOutlineHeight(visualScale) {
264
- return BASE_OUTLINE_HEIGHT * visualScale.y;
265
+ function getOutlineHeight() {
266
+ return BASE_OUTLINE_HEIGHT;
265
267
  }
266
- function getMaxStrandHeight(visualScale) {
267
- return Math.max(getCenterHeight(visualScale), getOutlineHeight(visualScale));
268
+ function getMaxStrandHeight() {
269
+ return Math.max(getCenterHeight(), getOutlineHeight());
268
270
  }
269
271
  function normalizeBuildingUpgradeScale(scale) {
270
272
  const x = Math.max(0.05, Math.abs(scale.x));
271
- const y = Math.max(0.05, Math.abs(scale.y));
272
273
  const z = Math.max(0.05, Math.abs(scale.z));
273
274
  return {
274
275
  x,
275
- y,
276
276
  z,
277
277
  horizontal: Math.max(0.05, (x + z) * 0.5)
278
278
  };
@@ -0,0 +1,9 @@
1
+ declare const _default: {
2
+ "id": "magnet-suction",
3
+ "nameZh": "磁铁吸入线",
4
+ "assets": [],
5
+ "source": "procedural"
6
+ }
7
+ ;
8
+
9
+ export default _default;
@@ -0,0 +1,35 @@
1
+ import { VfxColorValue, VfxEffectPackage, VfxParamValues } from '../../types';
2
+ interface MagnetSuctionParams extends VfxParamValues {
3
+ lifetimeMode: string;
4
+ lifetime: number;
5
+ scale: number;
6
+ initialHeight: number;
7
+ color: VfxColorValue;
8
+ opacity: number;
9
+ intensity: number;
10
+ forwardOffset: number;
11
+ suctionLength: number;
12
+ startRadius: number;
13
+ spreadY: number;
14
+ endRadius: number;
15
+ mouthPadding: number;
16
+ fadeDistance: number;
17
+ spawnFade: number;
18
+ suctionSpeed: number;
19
+ lineCount: number;
20
+ lineLength: number;
21
+ lineWidth: number;
22
+ lineOpacity: number;
23
+ lineSpread: number;
24
+ lineTwist: number;
25
+ particleCount: number;
26
+ particleSize: number;
27
+ particleSpeed: number;
28
+ particleDrift: number;
29
+ particleOuterOffset: number;
30
+ particleOpacity: number;
31
+ directionX: number;
32
+ directionZ: number;
33
+ }
34
+ export declare const magnetSuctionEffectPackage: VfxEffectPackage<MagnetSuctionParams>;
35
+ export default magnetSuctionEffectPackage;
@@ -0,0 +1,419 @@
1
+ import { Constants } from "@babylonjs/core/Engines/constants";
2
+ import { ShaderStore } from "@babylonjs/core/Engines/shaderStore";
3
+ import { Color3 } from "@babylonjs/core/Maths/math.color";
4
+ import { Vector3 } from "@babylonjs/core/Maths/math.vector";
5
+ import { VertexBuffer } from "@babylonjs/core/Buffers/buffer";
6
+ import { Material } from "@babylonjs/core/Materials/material";
7
+ import { ShaderMaterial } from "@babylonjs/core/Materials/shaderMaterial";
8
+ import { Mesh } from "@babylonjs/core/Meshes/mesh";
9
+ import { VertexData } from "@babylonjs/core/Meshes/mesh.vertexData";
10
+ import { TransformNode } from "@babylonjs/core/Meshes/transformNode";
11
+ import { V as VFX_RENDERING_GROUP_ID } from "../../chunks/rendering-CV3nhygX.js";
12
+ import { s as scheduleLifetime } from "../../chunks/space-shared-BXV1hc0k.js";
13
+ const id = "magnet-suction";
14
+ const nameZh = "磁铁吸入线";
15
+ const assets = [];
16
+ const source = "procedural";
17
+ const manifest = {
18
+ id,
19
+ nameZh,
20
+ assets,
21
+ source
22
+ };
23
+ const DEFAULT_PARAMS = {
24
+ lifetimeMode: "infinite",
25
+ lifetime: 2.5,
26
+ scale: 1.15,
27
+ initialHeight: 0,
28
+ color: { r: 0.82, g: 0.95, b: 1 },
29
+ opacity: 1,
30
+ intensity: 1,
31
+ forwardOffset: 0,
32
+ suctionLength: 2.25,
33
+ startRadius: 0.7,
34
+ spreadY: 0.34,
35
+ endRadius: 0.08,
36
+ mouthPadding: 0.18,
37
+ fadeDistance: 0.58,
38
+ spawnFade: 0.45,
39
+ suctionSpeed: 1.65,
40
+ lineCount: 32,
41
+ lineLength: 0.46,
42
+ lineWidth: 0.045,
43
+ lineOpacity: 1,
44
+ lineSpread: 1,
45
+ lineTwist: 0,
46
+ particleCount: 14,
47
+ particleSize: 0.032,
48
+ particleSpeed: 2.15,
49
+ particleDrift: 0,
50
+ particleOuterOffset: 0,
51
+ particleOpacity: 0.55,
52
+ directionX: 0,
53
+ directionZ: -1
54
+ };
55
+ const magnetSuctionEffectPackage = {
56
+ id: "magnet-suction",
57
+ nameZh: "磁铁吸入线",
58
+ placement: "socket",
59
+ geometrySpace: "local",
60
+ manifest,
61
+ defaultParams: DEFAULT_PARAMS,
62
+ debugAnchor: "playerRight",
63
+ debugParams: [
64
+ { key: "lifetimeMode", label: "生命周期模式", kind: "enum", options: [
65
+ { value: "infinite", label: "无限" },
66
+ { value: "duration", label: "自定义时间" }
67
+ ] },
68
+ { key: "lifetime", label: "自定义时长", kind: "lifetime", min: 0.1, max: 30, step: 0.01 },
69
+ { key: "scale", label: "整体缩放", kind: "scale", min: 0.05, max: 6, step: 0.01 },
70
+ { key: "initialHeight", label: "局部高度", kind: "number", min: -2, max: 4, step: 0.01 },
71
+ { key: "color", label: "吸入颜色", kind: "color" },
72
+ { key: "opacity", label: "整体透明度", kind: "opacity", min: 0, max: 1, step: 0.01 },
73
+ { key: "intensity", label: "强度", kind: "opacity", min: 0, max: 1, step: 0.01 },
74
+ { key: "forwardOffset", label: "入口前偏移", kind: "number", min: -2, max: 4, step: 0.01 },
75
+ { key: "suctionLength", label: "远近长度", kind: "number", min: 0.1, max: 8, step: 0.01 },
76
+ { key: "startRadius", label: "远处横向半径", kind: "number", min: 0.02, max: 5, step: 0.01 },
77
+ { key: "spreadY", label: "远处纵向半径", kind: "number", min: 0.02, max: 4, step: 0.01 },
78
+ { key: "endRadius", label: "近处半径", kind: "number", min: 0, max: 2, step: 0.01 },
79
+ { key: "mouthPadding", label: "入口保护距离", kind: "number", min: 0.01, max: 2, step: 0.01 },
80
+ { key: "fadeDistance", label: "入口淡出距离", kind: "number", min: 0.01, max: 3, step: 0.01 },
81
+ { key: "spawnFade", label: "外侧淡入距离", kind: "number", min: 0.01, max: 3, step: 0.01 },
82
+ { key: "suctionSpeed", label: "线条速度", kind: "number", min: 0, max: 8, step: 0.01 },
83
+ { key: "lineCount", label: "白线数量", kind: "number", min: 0, max: 96, step: 1 },
84
+ { key: "lineLength", label: "白线长度", kind: "number", min: 0.02, max: 2, step: 0.01 },
85
+ { key: "lineWidth", label: "白线尾宽", kind: "number", min: 2e-3, max: 0.18, step: 1e-3 },
86
+ { key: "lineOpacity", label: "白线透明度", kind: "opacity", min: 0, max: 1.5, step: 0.01 },
87
+ { key: "lineSpread", label: "散布缩放", kind: "number", min: 0.1, max: 2, step: 0.01 },
88
+ { key: "particleCount", label: "粒子数量", kind: "number", min: 0, max: 80, step: 1 },
89
+ { key: "particleSize", label: "粒子大小", kind: "number", min: 5e-3, max: 0.3, step: 1e-3 },
90
+ { key: "particleSpeed", label: "粒子速度", kind: "number", min: 0, max: 10, step: 0.01 },
91
+ { key: "particleOpacity", label: "粒子透明度", kind: "opacity", min: 0, max: 1, step: 0.01 },
92
+ { key: "directionX", label: "吸入方向 X", kind: "number", min: -1, max: 1, step: 0.01 },
93
+ { key: "directionZ", label: "吸入方向 Z", kind: "number", min: -1, max: 1, step: 0.01 }
94
+ ],
95
+ create({ scene, root, params, renderingGroupId }) {
96
+ const fieldRoot = new TransformNode("magnet-suction.root", scene);
97
+ fieldRoot.parent = root;
98
+ fieldRoot.position.set(0, params.initialHeight, 0);
99
+ const effect = createMagnetSuctionField(scene, fieldRoot, params, renderingGroupId ?? VFX_RENDERING_GROUP_ID);
100
+ let disposed = false;
101
+ let lifetimeTimer = 0;
102
+ effect.start();
103
+ lifetimeTimer = scheduleLifetime(params, disposeAll);
104
+ return {
105
+ root,
106
+ dispose: disposeAll
107
+ };
108
+ function disposeAll() {
109
+ if (disposed) return;
110
+ disposed = true;
111
+ if (lifetimeTimer) window.clearTimeout(lifetimeTimer);
112
+ effect.dispose();
113
+ fieldRoot.dispose();
114
+ root.dispose();
115
+ }
116
+ }
117
+ };
118
+ function createMagnetSuctionField(scene, root, params, renderingGroupId) {
119
+ const material = createSuctionMaterial(scene);
120
+ const mesh = createSuctionMesh(scene, root, params, material, renderingGroupId);
121
+ let observer = null;
122
+ let disposed = false;
123
+ return { start, dispose };
124
+ function start() {
125
+ if (observer) return;
126
+ observer = scene.onBeforeRenderObservable.add(() => {
127
+ if (disposed) return;
128
+ const deltaSeconds = Math.min(0.05, scene.getEngine().getDeltaTime() / 1e3);
129
+ mesh.update(deltaSeconds);
130
+ });
131
+ }
132
+ function dispose() {
133
+ if (disposed) return;
134
+ disposed = true;
135
+ if (observer) scene.onBeforeRenderObservable.remove(observer);
136
+ observer = null;
137
+ mesh.dispose();
138
+ material.dispose();
139
+ }
140
+ }
141
+ function createSuctionMesh(scene, root, params, material, renderingGroupId) {
142
+ const lineCount = Math.max(0, Math.round(params.lineCount));
143
+ const particleCount = Math.max(0, Math.round(params.particleCount));
144
+ const lineVertexCount = lineCount * 3;
145
+ const particleVertexCount = particleCount * 4;
146
+ const totalVertexCount = lineVertexCount + particleVertexCount;
147
+ const positions = new Float32Array(totalVertexCount * 3);
148
+ const colors = new Float32Array(totalVertexCount * 4);
149
+ const indices = [];
150
+ const lines = [];
151
+ const particles = [];
152
+ let resetIndex = 1;
153
+ for (let index = 0; index < lineCount; index += 1) {
154
+ const base = index * 3;
155
+ indices.push(base, base + 1, base + 2);
156
+ lines.push(resetLine({}, true));
157
+ }
158
+ for (let index = 0; index < particleCount; index += 1) {
159
+ const base = lineVertexCount + index * 4;
160
+ indices.push(base, base + 1, base + 2, base, base + 2, base + 3);
161
+ particles.push(resetParticle({}, true));
162
+ }
163
+ const mesh = new Mesh("magnet-suction.mesh", scene);
164
+ mesh.parent = root;
165
+ mesh.material = material;
166
+ mesh.hasVertexAlpha = true;
167
+ mesh.useVertexColors = true;
168
+ mesh.alwaysSelectAsActiveMesh = true;
169
+ mesh.renderingGroupId = renderingGroupId;
170
+ mesh.isPickable = false;
171
+ const vertexData = new VertexData();
172
+ vertexData.positions = positions;
173
+ vertexData.colors = colors;
174
+ vertexData.indices = indices;
175
+ vertexData.applyToMesh(mesh, true);
176
+ writeGeometry();
177
+ return { update, dispose };
178
+ function update(deltaSeconds) {
179
+ const basis = createSuctionBasis(params);
180
+ for (const line of lines) {
181
+ line.d -= line.speed * deltaSeconds;
182
+ if (line.d < basis.mouthPadding) resetLine(line, false);
183
+ }
184
+ for (const particle of particles) {
185
+ particle.d -= particle.speed * deltaSeconds;
186
+ if (particle.d < basis.mouthPadding) resetParticle(particle, false);
187
+ }
188
+ writeGeometry();
189
+ }
190
+ function dispose() {
191
+ mesh.dispose();
192
+ }
193
+ function writeGeometry() {
194
+ const basis = createSuctionBasis(params);
195
+ const cameraBasis = createCameraBasis(scene, root, basis);
196
+ const color = toColor3(params.color);
197
+ const alphaScale = clamp01(params.opacity) * Math.max(0, params.intensity);
198
+ let vertexIndex = 0;
199
+ const putVertex = (position, alpha, brightness = 1) => {
200
+ const positionOffset = vertexIndex * 3;
201
+ const colorOffset = vertexIndex * 4;
202
+ positions[positionOffset] = position.x;
203
+ positions[positionOffset + 1] = position.y;
204
+ positions[positionOffset + 2] = position.z;
205
+ colors[colorOffset] = clamp01(color.r * brightness);
206
+ colors[colorOffset + 1] = clamp01(color.g * brightness);
207
+ colors[colorOffset + 2] = clamp01(color.b * brightness);
208
+ colors[colorOffset + 3] = clamp01(alpha * alphaScale);
209
+ vertexIndex += 1;
210
+ };
211
+ for (const line of lines) {
212
+ const alpha = lineFade(line.d, basis) * params.lineOpacity * line.alphaMul;
213
+ const headD = Math.max(basis.mouthPadding, line.d);
214
+ const tailD = Math.min(basis.length, line.d + line.len);
215
+ const head = suctionPosition(headD, line.u, line.v, basis);
216
+ const tail = suctionPosition(tailD, line.u, line.v, basis);
217
+ const shrink = Math.max(lateralScale(headD, basis), 0.18);
218
+ const half = cameraBasis.ribbonSide.scale(line.width * shrink * 0.5);
219
+ putVertex(tail.subtract(half), alpha, line.brightness);
220
+ putVertex(tail.add(half), alpha * 0.92, line.brightness);
221
+ putVertex(head, alpha * 0.22, line.brightness);
222
+ }
223
+ for (const particle of particles) {
224
+ const alpha = lineFade(particle.d, basis) * params.particleOpacity * particle.alphaMul;
225
+ const center = suctionPosition(particle.d, particle.u, particle.v, basis);
226
+ const shrink = Math.max(lateralScale(particle.d, basis), 0.22);
227
+ const right = cameraBasis.particleRight.scale(particle.size * shrink);
228
+ const up = cameraBasis.particleUp.scale(particle.size * shrink * 0.7);
229
+ putVertex(center.subtract(right).subtractInPlace(up), alpha, particle.brightness);
230
+ putVertex(center.add(right).subtractInPlace(up), alpha, particle.brightness);
231
+ putVertex(center.add(right).addInPlace(up), alpha * 0.8, particle.brightness);
232
+ putVertex(center.subtract(right).addInPlace(up), alpha * 0.8, particle.brightness);
233
+ }
234
+ mesh.updateVerticesData(VertexBuffer.PositionKind, positions, false, false);
235
+ mesh.updateVerticesData(VertexBuffer.ColorKind, colors, false, false);
236
+ }
237
+ function resetLine(line, initial) {
238
+ const basis = createSuctionBasis(params);
239
+ line.d = initial ? randomRange(basis.mouthPadding + 0.05, basis.length) : basis.length + randomRange(0, basis.spawnFade);
240
+ line.u = randomRange(-basis.spreadX * 0.5, basis.spreadX * 0.5);
241
+ line.v = randomRange(-basis.spreadY * 0.5, basis.spreadY * 0.5);
242
+ line.len = randomRange(basis.lineLength * 0.65, basis.lineLength * 1.35);
243
+ line.width = randomRange(basis.lineWidth * 0.65, basis.lineWidth * 1.35);
244
+ line.speed = Math.max(0, params.suctionSpeed * Math.max(0.01, params.scale)) * randomRange(0.78, 1.25);
245
+ line.alphaMul = randomRange(0.65, 1);
246
+ line.brightness = randomRange(0.82, 1);
247
+ return line;
248
+ }
249
+ function resetParticle(particle, initial) {
250
+ const basis = createSuctionBasis(params);
251
+ particle.d = initial ? randomRange(basis.mouthPadding + 0.05, basis.length) : basis.length + randomRange(0, basis.spawnFade);
252
+ particle.u = randomRange(-basis.spreadX * 0.55, basis.spreadX * 0.55);
253
+ particle.v = randomRange(-basis.spreadY * 0.55, basis.spreadY * 0.55);
254
+ particle.size = randomRange(basis.particleSize * 0.45, basis.particleSize * 1.15);
255
+ particle.speed = Math.max(0, params.particleSpeed * Math.max(0.01, params.scale)) * randomRange(0.75, 1.35);
256
+ particle.alphaMul = randomRange(0.35, 0.9);
257
+ particle.brightness = randomRange(0.75, 1);
258
+ return particle;
259
+ }
260
+ function randomRange(min, max) {
261
+ const id2 = resetIndex;
262
+ resetIndex += 1;
263
+ return lerp(min, max, sequence01(id2, resetIndex % 8));
264
+ }
265
+ }
266
+ function createSuctionBasis(params) {
267
+ const scale = Math.max(0.01, params.scale);
268
+ const axis = new Vector3(params.directionX, 0, params.directionZ);
269
+ if (axis.lengthSquared() < 1e-4) axis.set(0, 0, 1);
270
+ axis.normalize();
271
+ let up = Vector3.Up();
272
+ if (Math.abs(Vector3.Dot(up, axis)) > 0.94) up = Vector3.Forward();
273
+ let right = Vector3.Cross(up, axis);
274
+ if (right.lengthSquared() < 1e-4) right = Vector3.Right();
275
+ right.normalize();
276
+ let realUp = Vector3.Cross(axis, right);
277
+ if (realUp.lengthSquared() < 1e-4) realUp = Vector3.Up();
278
+ realUp.normalize();
279
+ const lineSpread = Math.max(0.1, params.lineSpread);
280
+ const length = Math.max(0.01, params.suctionLength * scale);
281
+ const mouthPadding = Math.max(1e-3, (params.mouthPadding || params.endRadius || 0.12) * scale);
282
+ const outerRadius = Math.max(1e-3, Math.max(params.startRadius, params.spreadY) * lineSpread * scale);
283
+ const endRadius = Math.max(0, params.endRadius * scale);
284
+ return {
285
+ axis,
286
+ right,
287
+ up: realUp,
288
+ mouth: axis.scale(params.forwardOffset * scale),
289
+ length,
290
+ spreadX: Math.max(1e-3, params.startRadius * lineSpread * scale),
291
+ spreadY: Math.max(1e-3, params.spreadY * lineSpread * scale),
292
+ mouthPadding: Math.min(length, mouthPadding),
293
+ fadeDistance: Math.max(1e-3, params.fadeDistance * scale),
294
+ spawnFade: Math.max(1e-3, params.spawnFade * scale),
295
+ endRadiusRatio: clamp01(endRadius / outerRadius),
296
+ lineLength: Math.max(0.01, params.lineLength * scale),
297
+ lineWidth: Math.max(1e-3, params.lineWidth * scale),
298
+ particleSize: Math.max(1e-3, params.particleSize * scale)
299
+ };
300
+ }
301
+ function suctionPosition(distance, u, v, basis) {
302
+ const laneScale = lateralScale(distance, basis);
303
+ const lane = basis.right.scale(u * laneScale).addInPlace(basis.up.scale(v * laneScale));
304
+ return basis.mouth.subtract(basis.axis.scale(distance)).addInPlace(lane);
305
+ }
306
+ function lateralScale(distance, basis) {
307
+ const travel = clamp01((distance - basis.mouthPadding) / Math.max(1e-5, basis.length - basis.mouthPadding));
308
+ const eased = travel * travel;
309
+ return lerp(basis.endRadiusRatio, 1, eased);
310
+ }
311
+ function createCameraBasis(scene, root, basis) {
312
+ let ribbonSide = basis.right.clone();
313
+ let particleRight = basis.right.clone();
314
+ let particleUp = basis.up.clone();
315
+ const camera = scene.activeCamera;
316
+ if (camera) {
317
+ const cameraPosition = camera.globalPosition ?? camera.position;
318
+ if (cameraPosition) {
319
+ const inverseRoot = root.getWorldMatrix().clone();
320
+ inverseRoot.invert();
321
+ const localCameraPosition = Vector3.TransformCoordinates(cameraPosition, inverseRoot);
322
+ const view = localCameraPosition.subtract(basis.mouth);
323
+ if (view.lengthSquared() > 1e-4) {
324
+ view.normalize();
325
+ const side = Vector3.Cross(basis.axis, view);
326
+ if (side.lengthSquared() > 1e-4) {
327
+ side.normalize();
328
+ ribbonSide = side;
329
+ }
330
+ }
331
+ }
332
+ if (camera.getDirection) {
333
+ const inverseRoot = root.getWorldMatrix().clone();
334
+ inverseRoot.invert();
335
+ const worldRight = camera.getDirection(Vector3.Right());
336
+ const worldUp = camera.getDirection(Vector3.Up());
337
+ const localRight = Vector3.TransformNormal(worldRight, inverseRoot);
338
+ const localUp = Vector3.TransformNormal(worldUp, inverseRoot);
339
+ if (localRight.lengthSquared() > 1e-4) {
340
+ localRight.normalize();
341
+ particleRight = localRight;
342
+ }
343
+ if (localUp.lengthSquared() > 1e-4) {
344
+ localUp.normalize();
345
+ particleUp = localUp;
346
+ }
347
+ }
348
+ }
349
+ return { ribbonSide, particleRight, particleUp };
350
+ }
351
+ function createSuctionMaterial(scene) {
352
+ registerSuctionShaders();
353
+ const material = new ShaderMaterial("magnet-suction.material", scene, {
354
+ vertex: "magnetSuction",
355
+ fragment: "magnetSuction"
356
+ }, {
357
+ attributes: ["position", "color"],
358
+ uniforms: ["worldViewProjection"],
359
+ needAlphaBlending: true
360
+ });
361
+ material.backFaceCulling = false;
362
+ material.transparencyMode = Material.MATERIAL_ALPHABLEND;
363
+ material.alphaMode = Constants.ALPHA_COMBINE;
364
+ material.disableDepthWrite = true;
365
+ return material;
366
+ }
367
+ function registerSuctionShaders() {
368
+ if (!ShaderStore.ShadersStore.magnetSuctionVertexShader) {
369
+ ShaderStore.ShadersStore.magnetSuctionVertexShader = `
370
+ precision highp float;
371
+ attribute vec3 position;
372
+ attribute vec4 color;
373
+ uniform mat4 worldViewProjection;
374
+ varying vec4 vColor;
375
+ void main(void) {
376
+ gl_Position = worldViewProjection * vec4(position, 1.0);
377
+ vColor = color;
378
+ }
379
+ `;
380
+ }
381
+ if (!ShaderStore.ShadersStore.magnetSuctionFragmentShader) {
382
+ ShaderStore.ShadersStore.magnetSuctionFragmentShader = `
383
+ precision highp float;
384
+ varying vec4 vColor;
385
+ void main(void) {
386
+ if (vColor.a <= 0.01) discard;
387
+ gl_FragColor = vColor;
388
+ }
389
+ `;
390
+ }
391
+ }
392
+ function lineFade(distance, basis) {
393
+ const mouthFade = smoothstep(basis.mouthPadding, basis.mouthPadding + basis.fadeDistance, distance);
394
+ const clampedDistance = Math.min(distance, basis.length);
395
+ const outerFade = smoothstep(0, basis.spawnFade, basis.length - clampedDistance);
396
+ return mouthFade * outerFade;
397
+ }
398
+ function toColor3(color) {
399
+ return new Color3(clamp01(color.r), clamp01(color.g), clamp01(color.b));
400
+ }
401
+ function lerp(min, max, t) {
402
+ return min + (max - min) * t;
403
+ }
404
+ function clamp01(value) {
405
+ return Math.min(1, Math.max(0, value));
406
+ }
407
+ function smoothstep(edge0, edge1, value) {
408
+ const t = clamp01((value - edge0) / Math.max(1e-5, edge1 - edge0));
409
+ return t * t * (3 - 2 * t);
410
+ }
411
+ function sequence01(index, axis) {
412
+ if (index === 0) return 0.5;
413
+ const strides = [0.618033988749895, 0.414213562373095, 0.732050807568877, 0.324717957244746, 0.754877666246693, 0.569840290998053, 0.438744359656398, 0.280776406404415];
414
+ return (0.5 + index * strides[axis % strides.length] + axis * 0.173205080756888) % 1;
415
+ }
416
+ export {
417
+ magnetSuctionEffectPackage as default,
418
+ magnetSuctionEffectPackage
419
+ };
@@ -16,5 +16,6 @@ export declare const woodCrusherSawdustEffectPackage: {
16
16
  requiredInputs?: import('../..').VfxInputKey[];
17
17
  optionalInputs?: import('../..').VfxInputKey[];
18
18
  geometrySpace?: "local" | "world";
19
+ applyRootScale?: boolean;
19
20
  create(context: import('../..').VfxEffectPackageContext<import('../..').VfxParamValues>): import('../..').VfxEffectHandle;
20
21
  };
@@ -16,5 +16,6 @@ export declare const woodCutterSawdustEffectPackage: {
16
16
  requiredInputs?: import('../..').VfxInputKey[];
17
17
  optionalInputs?: import('../..').VfxInputKey[];
18
18
  geometrySpace?: "local" | "world";
19
+ applyRootScale?: boolean;
19
20
  create(context: import('../..').VfxEffectPackageContext<import('../..').VfxParamValues>): import('../..').VfxEffectHandle;
20
21
  };
package/dist/index.js CHANGED
@@ -114,7 +114,8 @@ class EffectPackageService {
114
114
  warnMissingRequiredInputs(effectId, effectPackage.requiredInputs, spawn.inputs);
115
115
  const runtimeParams = stripSpawnParams(params, effectPackage.spawnParams);
116
116
  const root = new TransformNode(`vfx_package_${effectId}`, this.scene);
117
- applySpawnToRoot(root, spawn);
117
+ const applyRootScale = effectPackage.applyRootScale !== false;
118
+ applySpawnToRoot(root, spawn, applyRootScale);
118
119
  const worldPosition = root.getAbsolutePosition().clone();
119
120
  let handle;
120
121
  try {
@@ -128,7 +129,7 @@ class EffectPackageService {
128
129
  scale: spawn.scale,
129
130
  inputs: spawn.inputs
130
131
  });
131
- applySpawnToRoot(root, spawn);
132
+ applySpawnToRoot(root, spawn, applyRootScale);
132
133
  } catch (error) {
133
134
  console.warn(`[EffectPackageService] effect "${effectId}" create() failed; skipped.`, error);
134
135
  try {
@@ -212,10 +213,11 @@ function warnMissingRequiredInputs(effectId, required, inputs) {
212
213
  console.warn(`[EffectPackageService] effect "${effectId}" 声明 requiredInputs [${missing.join(", ")}] 但未提供 → 可能静默降级。`);
213
214
  }
214
215
  }
215
- function applySpawnToRoot(root, spawn) {
216
+ function applySpawnToRoot(root, spawn, applyRootScale = true) {
216
217
  if (spawn.parent) root.parent = spawn.parent;
217
218
  root.rotation.copyFrom(spawn.rotation);
218
- root.scaling.copyFrom(spawn.scale);
219
+ if (applyRootScale) root.scaling.copyFrom(spawn.scale);
220
+ else root.scaling.set(1, 1, 1);
219
221
  if (spawn.parent && !spawn.offsetIsLocal) {
220
222
  root.computeWorldMatrix(true);
221
223
  root.setAbsolutePosition(spawn.position);
package/dist/types.d.ts CHANGED
@@ -157,6 +157,12 @@ export interface VfxEffectPackage<PParams extends VfxParamValues = VfxParamValue
157
157
  * 默认随 placement 推断('socket'→local,其余→world)。
158
158
  */
159
159
  geometrySpace?: 'local' | 'world';
160
+ /**
161
+ * 是否把 spawnTransform.scale 应用到 root.scaling。默认 true。
162
+ * 对需要自行解释 scale 的特效(例如只把 scale 当作发射 footprint 尺寸)可设为 false,
163
+ * 此时 service 仍会把 scale 传入 create(context.scale),但 root 保持单位缩放。
164
+ */
165
+ applyRootScale?: boolean;
160
166
  /**
161
167
  * 构建特效。**约定(G6):root 的 transform(position/rotation/scaling/parent)归 service 独占** ——
162
168
  * service 在 create() 后会二次 `applySpawnToRoot` 覆盖,create 内写 `root.position/rotation/scaling` 会被静默清掉。
package/index.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": "vfx-index/1.0",
3
- "count": 37,
3
+ "count": 38,
4
4
  "effects": [
5
5
  {
6
6
  "id": "build.particle.lumber_debris_scatter",
@@ -217,6 +217,16 @@
217
217
  "media": {},
218
218
  "path": "packages/effects/lumber-cart-suction"
219
219
  },
220
+ {
221
+ "id": "magnet-suction",
222
+ "dir": "magnet-suction",
223
+ "nameZh": "磁铁吸入线",
224
+ "kind": "code",
225
+ "category": "magnet-suction",
226
+ "tags": [],
227
+ "media": {},
228
+ "path": "packages/effects/magnet-suction"
229
+ },
220
230
  {
221
231
  "id": "monster-hit",
222
232
  "dir": "monster-hit",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fps-games/vfx",
3
- "version": "0.3.7",
3
+ "version": "0.3.9",
4
4
  "type": "module",
5
5
  "description": "Shared VFX library for Playable projects: engine-injected effect packages + runtime. Project-pure (no imports of host project code).",
6
6
  "publishConfig": {
@@ -0,0 +1,36 @@
1
+ {
2
+ "lifetimeMode": "infinite",
3
+ "lifetime": 2.5,
4
+ "scale": 1.15,
5
+ "initialHeight": 0,
6
+ "color": {
7
+ "r": 0.82,
8
+ "g": 0.95,
9
+ "b": 1
10
+ },
11
+ "opacity": 1,
12
+ "intensity": 1,
13
+ "forwardOffset": 0,
14
+ "suctionLength": 2.25,
15
+ "startRadius": 0.7,
16
+ "spreadY": 0.34,
17
+ "endRadius": 0.08,
18
+ "mouthPadding": 0.18,
19
+ "fadeDistance": 0.58,
20
+ "spawnFade": 0.45,
21
+ "suctionSpeed": 1.65,
22
+ "lineCount": 32,
23
+ "lineLength": 0.46,
24
+ "lineWidth": 0.045,
25
+ "lineOpacity": 1,
26
+ "lineSpread": 1,
27
+ "lineTwist": 0,
28
+ "particleCount": 14,
29
+ "particleSize": 0.032,
30
+ "particleSpeed": 2.15,
31
+ "particleDrift": 0,
32
+ "particleOuterOffset": 0,
33
+ "particleOpacity": 0.55,
34
+ "directionX": 0,
35
+ "directionZ": -1
36
+ }