@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.
- package/CHANGELOG.md +107 -0
- package/README.md +41 -0
- package/dist/effects/coin-burst/index.js +6 -2
- package/dist/effects/comet-trail-flame/index.js +3 -1
- package/dist/effects/debris-smoke/index.js +1 -0
- package/dist/effects/electric-arc/index.js +1 -0
- package/dist/effects/energy-shield/index.js +1 -0
- package/dist/effects/explosion/index.js +3 -2
- package/dist/effects/hit-flash/index.js +12 -4
- package/dist/effects/laser-beam/index.js +14 -2
- package/dist/effects/lumber_vehicle_upgrade_aura/index.js +1 -0
- package/dist/effects/monster-hit/index.js +2 -1
- package/dist/effects/player-upgrade/index.js +5 -3
- package/dist/effects/pulse-laser-volley/index.js +14 -4
- package/dist/effects/thruster-flame/index.js +3 -1
- package/dist/effects/treasure-attention/index.d.ts +54 -0
- package/dist/effects/treasure-attention/index.js +535 -0
- package/dist/effects/ufo-thread-flame/index.js +1 -0
- package/dist/effects/vehicle-upgrade/index.js +4 -1
- package/dist/effects/weapon-trail/index.js +8 -3
- package/dist/effects/wood_crusher_sawdust/index.d.ts +4 -0
- package/dist/effects/wood_cutter_sawdust/index.d.ts +4 -0
- package/dist/index.js +85 -17
- package/dist/types.d.ts +80 -0
- package/docs/INTEGRATION.md +194 -0
- package/docs/hit-flash-red-method.md +155 -0
- package/docs/treasure_attention_fx_babylonjs_guide.md +901 -0
- package/docs/vfx-placement-design.md +164 -0
- package/index.json +19 -1
- package/package.json +5 -1
- package/packages/EffectPackageService.ts +123 -17
- package/packages/effects/coin-burst/index.ts +7 -2
- package/packages/effects/comet-trail-flame/index.ts +3 -2
- package/packages/effects/debris-smoke/index.ts +1 -0
- package/packages/effects/electric-arc/index.ts +1 -0
- package/packages/effects/energy-shield/index.ts +1 -0
- package/packages/effects/explosion/index.ts +3 -2
- package/packages/effects/hit-flash/index.ts +11 -3
- package/packages/effects/laser-beam-factory.ts +14 -3
- package/packages/effects/lumber_vehicle_upgrade_aura/index.ts +2 -0
- package/packages/effects/monster-hit/index.ts +2 -1
- package/packages/effects/player-upgrade/index.ts +8 -3
- package/packages/effects/pulse-laser-volley/index.ts +15 -4
- package/packages/effects/thruster-flame/index.ts +3 -2
- package/packages/effects/treasure-attention/effect.json +7 -0
- package/packages/effects/treasure-attention/index.ts +630 -0
- package/packages/effects/treasure-attention/vfx-params.json +49 -0
- package/packages/effects/ufo-thread-flame/index.ts +1 -0
- package/packages/effects/vehicle-upgrade/index.ts +4 -1
- package/packages/effects/weapon-trail/index.ts +7 -3
- package/packages/types.ts +88 -0
package/dist/index.js
CHANGED
|
@@ -111,20 +111,24 @@ class EffectPackageService {
|
|
|
111
111
|
const params = this.getMergedParams(effectId, explicitParams);
|
|
112
112
|
if (!params) return null;
|
|
113
113
|
const spawn = this.resolveSpawnTransform(effectPackage, params, spawnTransform);
|
|
114
|
+
warnMissingRequiredInputs(effectId, effectPackage.requiredInputs, spawn.inputs);
|
|
114
115
|
const runtimeParams = stripSpawnParams(params, effectPackage.spawnParams);
|
|
115
116
|
const root = new TransformNode(`vfx_package_${effectId}`, this.scene);
|
|
116
|
-
root
|
|
117
|
-
|
|
117
|
+
applySpawnToRoot(root, spawn);
|
|
118
|
+
const worldPosition = root.getAbsolutePosition().clone();
|
|
118
119
|
let handle;
|
|
119
120
|
try {
|
|
120
121
|
handle = effectPackage.create({
|
|
121
122
|
scene: this.scene,
|
|
122
123
|
root,
|
|
123
|
-
position:
|
|
124
|
+
position: worldPosition,
|
|
124
125
|
rotationY: spawn.rotationY,
|
|
125
126
|
renderingGroupId: spawn.renderingGroupId,
|
|
126
|
-
params: runtimeParams
|
|
127
|
+
params: runtimeParams,
|
|
128
|
+
scale: spawn.scale,
|
|
129
|
+
inputs: spawn.inputs
|
|
127
130
|
});
|
|
131
|
+
applySpawnToRoot(root, spawn);
|
|
128
132
|
} catch (error) {
|
|
129
133
|
console.warn(`[EffectPackageService] effect "${effectId}" create() failed; skipped.`, error);
|
|
130
134
|
try {
|
|
@@ -139,6 +143,9 @@ class EffectPackageService {
|
|
|
139
143
|
this.activeHandles.delete(handle);
|
|
140
144
|
originalDispose();
|
|
141
145
|
};
|
|
146
|
+
handle.root.onDisposeObservable.addOnce(() => {
|
|
147
|
+
this.activeHandles.delete(handle);
|
|
148
|
+
});
|
|
142
149
|
return handle;
|
|
143
150
|
}
|
|
144
151
|
dispose() {
|
|
@@ -157,26 +164,87 @@ class EffectPackageService {
|
|
|
157
164
|
}
|
|
158
165
|
resolveSpawnTransform(effectPackage, params, spawnTransform) {
|
|
159
166
|
const normalized = normalizeSpawnTransform(spawnTransform);
|
|
160
|
-
const
|
|
161
|
-
const
|
|
167
|
+
const parent = normalized.parent ?? null;
|
|
168
|
+
const basePosition = normalized.position?.clone() ?? (parent != null ? new Vector3(0, 0, 0) : this.resolveDebugPosition(effectPackage));
|
|
169
|
+
const rotation = resolveRotation(normalized.rotation, normalized.rotationY);
|
|
170
|
+
const scale = resolveScale(normalized.scale);
|
|
171
|
+
const offsetIsLocal = normalized.offsetIsLocal ?? parent != null;
|
|
162
172
|
const renderingGroupId = readOptionalFiniteNumber(normalized.renderingGroupId);
|
|
163
173
|
const offset = readSpawnOffset(params, effectPackage.spawnParams);
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
return {
|
|
170
|
-
position: basePosition.addInPlace(new Vector3(
|
|
174
|
+
let position = basePosition;
|
|
175
|
+
if (offset.lengthSquared() > 1e-7) {
|
|
176
|
+
const cos = Math.cos(rotation.y);
|
|
177
|
+
const sin = Math.sin(rotation.y);
|
|
178
|
+
position = basePosition.addInPlace(new Vector3(
|
|
171
179
|
offset.x * cos - offset.z * sin,
|
|
172
180
|
offset.y,
|
|
173
181
|
offset.x * sin + offset.z * cos
|
|
174
|
-
))
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
182
|
+
));
|
|
183
|
+
}
|
|
184
|
+
return { position, rotation, rotationY: rotation.y, scale, parent, offsetIsLocal, renderingGroupId, inputs: normalizeInputs(normalized.inputs) };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
function normalizeInputs(inputs) {
|
|
188
|
+
if (!inputs) return void 0;
|
|
189
|
+
const asFn = (v) => v == null ? void 0 : typeof v === "function" ? v : () => v;
|
|
190
|
+
const out = {};
|
|
191
|
+
if (inputs.aim != null) out.aim = asFn(inputs.aim);
|
|
192
|
+
if (inputs.normal != null) out.normal = asFn(inputs.normal);
|
|
193
|
+
if (inputs.endpoints) out.endpoints = inputs.endpoints.map((e) => asFn(e)).filter(Boolean);
|
|
194
|
+
if (inputs.target) out.target = inputs.target;
|
|
195
|
+
if (inputs.scalars) {
|
|
196
|
+
out.scalars = {};
|
|
197
|
+
for (const [k, v] of Object.entries(inputs.scalars)) {
|
|
198
|
+
const fn = asFn(v);
|
|
199
|
+
if (fn) out.scalars[k] = fn;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
function warnMissingRequiredInputs(effectId, required, inputs) {
|
|
205
|
+
if (!required || required.length === 0) return;
|
|
206
|
+
const missing = required.filter((k) => {
|
|
207
|
+
const v = inputs ? inputs[k] : void 0;
|
|
208
|
+
return v == null || k === "endpoints" && Array.isArray(v) && v.length < 2;
|
|
209
|
+
});
|
|
210
|
+
if (missing.length > 0) {
|
|
211
|
+
console.warn(`[EffectPackageService] effect "${effectId}" 声明 requiredInputs [${missing.join(", ")}] 但未提供 → 可能静默降级。`);
|
|
178
212
|
}
|
|
179
213
|
}
|
|
214
|
+
function applySpawnToRoot(root, spawn) {
|
|
215
|
+
if (spawn.parent) root.parent = spawn.parent;
|
|
216
|
+
root.rotation.copyFrom(spawn.rotation);
|
|
217
|
+
root.scaling.copyFrom(spawn.scale);
|
|
218
|
+
if (spawn.parent && !spawn.offsetIsLocal) {
|
|
219
|
+
root.computeWorldMatrix(true);
|
|
220
|
+
root.setAbsolutePosition(spawn.position);
|
|
221
|
+
} else {
|
|
222
|
+
root.position.copyFrom(spawn.position);
|
|
223
|
+
}
|
|
224
|
+
root.computeWorldMatrix(true);
|
|
225
|
+
}
|
|
226
|
+
function resolveRotation(rotation, rotationY) {
|
|
227
|
+
if (rotation) {
|
|
228
|
+
return new Vector3(
|
|
229
|
+
readFiniteNumber(rotation.x, 0),
|
|
230
|
+
readFiniteNumber(rotation.y, 0),
|
|
231
|
+
readFiniteNumber(rotation.z, 0)
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
return new Vector3(0, readFiniteNumber(rotationY, 0), 0);
|
|
235
|
+
}
|
|
236
|
+
function resolveScale(scale) {
|
|
237
|
+
if (scale == null) return new Vector3(1, 1, 1);
|
|
238
|
+
if (typeof scale === "number") {
|
|
239
|
+
const s2 = readFiniteNumber(scale, 1);
|
|
240
|
+
return new Vector3(s2, s2, s2);
|
|
241
|
+
}
|
|
242
|
+
return new Vector3(
|
|
243
|
+
readFiniteNumber(scale.x, 1),
|
|
244
|
+
readFiniteNumber(scale.y, 1),
|
|
245
|
+
readFiniteNumber(scale.z, 1)
|
|
246
|
+
);
|
|
247
|
+
}
|
|
180
248
|
function normalizeSpawnTransform(spawnTransform) {
|
|
181
249
|
if (!spawnTransform) return {};
|
|
182
250
|
if (spawnTransform instanceof Vector3) return { position: spawnTransform };
|
package/dist/types.d.ts
CHANGED
|
@@ -1,7 +1,27 @@
|
|
|
1
1
|
import { Scene } from '@babylonjs/core/scene';
|
|
2
2
|
import { Vector3 } from '@babylonjs/core/Maths/math.vector';
|
|
3
3
|
import { TransformNode } from '@babylonjs/core/Meshes/transformNode';
|
|
4
|
+
import { AbstractMesh } from '@babylonjs/core/Meshes/abstractMesh';
|
|
4
5
|
export type VfxDebugAnchor = 'playerRight' | 'player';
|
|
6
|
+
export type VfxInput<T> = T | (() => T);
|
|
7
|
+
export type VfxInputKey = 'aim' | 'normal' | 'endpoints' | 'target';
|
|
8
|
+
/** 调用方提供的 inputs(放在 VfxSpawnTransform.inputs)。 */
|
|
9
|
+
export interface VfxEffectInputs {
|
|
10
|
+
aim?: VfxInput<Vector3>;
|
|
11
|
+
normal?: VfxInput<Vector3>;
|
|
12
|
+
endpoints?: Array<VfxInput<Vector3>>;
|
|
13
|
+
target?: AbstractMesh;
|
|
14
|
+
/** 运行时标量(throttle/intensity 等),值可静态或 provider;特效每帧读取做活改(G5)。 */
|
|
15
|
+
scalars?: Record<string, VfxInput<number>>;
|
|
16
|
+
}
|
|
17
|
+
/** create() 收到的、已归一化为访问器的 inputs(provider 与静态值都变成可调用的函数)。 */
|
|
18
|
+
export interface VfxResolvedInputs {
|
|
19
|
+
aim?: () => Vector3;
|
|
20
|
+
normal?: () => Vector3;
|
|
21
|
+
endpoints?: Array<() => Vector3>;
|
|
22
|
+
target?: AbstractMesh;
|
|
23
|
+
scalars?: Record<string, () => number>;
|
|
24
|
+
}
|
|
5
25
|
export type VfxParamValue = number | string | VfxColorValue;
|
|
6
26
|
export type VfxParamValues = Record<string, VfxParamValue>;
|
|
7
27
|
export interface VfxColorValue {
|
|
@@ -41,11 +61,21 @@ export interface VfxEffectPackageContext<PParams extends VfxParamValues = VfxPar
|
|
|
41
61
|
rotationY: number;
|
|
42
62
|
renderingGroupId?: number;
|
|
43
63
|
params: PParams;
|
|
64
|
+
/**
|
|
65
|
+
* 解析后的三轴缩放(来自 spawnTransform.scale,默认 (1,1,1))。
|
|
66
|
+
* root 的缩放由 service 写好;但**几何不挂 root 的 world/detached 特效**(粒子/抛物池/世界坐标 emitter)
|
|
67
|
+
* root.scaling 作用不到,须自行把 ctx.scale 乘进几何尺寸/初速(G3)。
|
|
68
|
+
*/
|
|
69
|
+
scale: Vector3;
|
|
70
|
+
/** 运行时外部信息(已归一化为访问器)。link/target/定向武器/活改标量等按 required/optionalInputs 读取。 */
|
|
71
|
+
inputs?: VfxResolvedInputs;
|
|
44
72
|
}
|
|
45
73
|
export interface VfxEffectHandle {
|
|
46
74
|
root: TransformNode;
|
|
47
75
|
ready?: Promise<void>;
|
|
48
76
|
dispose(): void;
|
|
77
|
+
/** 可选:运行时活改 params(无须 dispose+replay,避免重置缓动/可见闪)。声明即纳入契约(G5)。 */
|
|
78
|
+
update?(params: Partial<VfxParamValues>): void;
|
|
49
79
|
}
|
|
50
80
|
export interface VfxEffectManifest {
|
|
51
81
|
id: string;
|
|
@@ -58,10 +88,34 @@ export interface VfxSpawnParamKeys {
|
|
|
58
88
|
offsetY?: string;
|
|
59
89
|
offsetZ?: string;
|
|
60
90
|
}
|
|
91
|
+
/** 欧拉角(弧度)/ 三轴缩放的纯数据三元组。 */
|
|
92
|
+
export interface VfxVector3Like {
|
|
93
|
+
x: number;
|
|
94
|
+
y: number;
|
|
95
|
+
z: number;
|
|
96
|
+
}
|
|
61
97
|
export interface VfxSpawnTransform {
|
|
62
98
|
position?: Vector3;
|
|
99
|
+
/**
|
|
100
|
+
* 兼容别名:绕 Y 轴旋转(弧度)。
|
|
101
|
+
* 若同时给了 `rotation`,以 `rotation` 为准;否则回退到 `rotationY`。
|
|
102
|
+
*/
|
|
63
103
|
rotationY?: number;
|
|
104
|
+
/** 完整欧拉角旋转(弧度);给了就覆盖 `rotationY`。 */
|
|
105
|
+
rotation?: VfxVector3Like;
|
|
106
|
+
/** 等比缩放(number)或三轴缩放;默认 1。 */
|
|
107
|
+
scale?: number | VfxVector3Like;
|
|
108
|
+
/** 挂载点节点;给了就把特效 root parent 到它(特效随之跟随)。 */
|
|
109
|
+
parent?: TransformNode;
|
|
110
|
+
/**
|
|
111
|
+
* position/rotation/scale 的坐标系解释:
|
|
112
|
+
* - 给了 `parent` 时默认 `true`:按相对 parent 的 **local** transform 解释;
|
|
113
|
+
* - 没给 `parent` 时默认 `false`:按 **世界坐标** 解释(保持现状行为)。
|
|
114
|
+
*/
|
|
115
|
+
offsetIsLocal?: boolean;
|
|
64
116
|
renderingGroupId?: number;
|
|
117
|
+
/** 运行时外部信息(瞄准点/法线/端点/目标 mesh);值可为静态或 provider(动态每帧取)。 */
|
|
118
|
+
inputs?: VfxEffectInputs;
|
|
65
119
|
}
|
|
66
120
|
export interface VfxParamsFile {
|
|
67
121
|
schemaVersion: string;
|
|
@@ -69,6 +123,15 @@ export interface VfxParamsFile {
|
|
|
69
123
|
updatedAt?: string | null;
|
|
70
124
|
params?: Partial<VfxParamValues>;
|
|
71
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* 特效的放置本质(内禀标记)。决定系统/binding/面板怎么放它,不靠猜。见 docs/vfx-placement-design.md。
|
|
128
|
+
* - 'socket' local:挂对象命名挂点、随它走(parent + follow)。
|
|
129
|
+
* - 'world' global:放世界某点、不跟随(fire-and-forget,可按法线定向)。
|
|
130
|
+
* - 'screen' global:屏幕/相机层(后处理/相机扰动/DOM 全屏),无 3D transform。
|
|
131
|
+
* - 'target' 改目标自身材质/渲染态,没有可 spawn 的 root。
|
|
132
|
+
* - 'link' 连 ≥2 个端点(每帧重建几何),单 parent 表达不了。
|
|
133
|
+
*/
|
|
134
|
+
export type VfxPlacement = 'socket' | 'world' | 'screen' | 'target' | 'link';
|
|
72
135
|
export interface VfxEffectPackage<PParams extends VfxParamValues = VfxParamValues> {
|
|
73
136
|
id: string;
|
|
74
137
|
nameZh: string;
|
|
@@ -78,6 +141,23 @@ export interface VfxEffectPackage<PParams extends VfxParamValues = VfxParamValue
|
|
|
78
141
|
spawnParams?: VfxSpawnParamKeys;
|
|
79
142
|
savedParams?: Partial<VfxParamValues>;
|
|
80
143
|
debugParams: VfxParamDefinition[];
|
|
144
|
+
/** 放置本质标记(默认 'world')。 */
|
|
145
|
+
placement?: VfxPlacement;
|
|
146
|
+
/** 声明**必需**的运行时 inputs(link 必须给 endpoints/aim、target 类必须给 target mesh);service 缺失时 warn。 */
|
|
147
|
+
requiredInputs?: VfxInputKey[];
|
|
148
|
+
/** 声明**可选**的运行时 inputs(给了就用、不给走默认,如定向武器的 aim:给了朝目标、不给走挂点朝向);不触发缺失 warn(G2)。 */
|
|
149
|
+
optionalInputs?: VfxInputKey[];
|
|
150
|
+
/**
|
|
151
|
+
* 几何空间:'local'=几何挂 root 子节点随它刚性变换;'world'=几何用世界坐标、root 仅作每帧采样源(拖尾)。
|
|
152
|
+
* 默认随 placement 推断('socket'→local,其余→world)。
|
|
153
|
+
*/
|
|
154
|
+
geometrySpace?: 'local' | 'world';
|
|
155
|
+
/**
|
|
156
|
+
* 构建特效。**约定(G6):root 的 transform(position/rotation/scaling/parent)归 service 独占** ——
|
|
157
|
+
* service 在 create() 后会二次 `applySpawnToRoot` 覆盖,create 内写 `root.position/rotation/scaling` 会被静默清掉。
|
|
158
|
+
* 需要内部局部偏移(如抬升)请用 root 下的**子 TransformNode** 承载,或声明 `spawnParams.offsetY` 走 service 存活通道。
|
|
159
|
+
* 几何挂 root 子树即随挂载跟随;world/拖尾类几何走世界坐标、读 ctx.scale 缩放。
|
|
160
|
+
*/
|
|
81
161
|
create(context: VfxEffectPackageContext<PParams>): VfxEffectHandle;
|
|
82
162
|
}
|
|
83
163
|
export type VfxDebugOverrides = Record<string, Partial<VfxParamValues>>;
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# 新项目集成 @fps/vfx(集成指南)
|
|
2
|
+
|
|
3
|
+
> 面向**新游戏项目**:如何消费 `@fps/vfx` 包、装配特效、在游戏逻辑里播放(含挂载/瞄准)。
|
|
4
|
+
> 代码片段均摘自真实消费方 `pa_template`(`/Users/admin/Code/FPS/pa_template`)。
|
|
5
|
+
> 写特效(作者侧)看 [`AUTHORING_EFFECTS.md`](./AUTHORING_EFFECTS.md);放置/inputs 设计看 [`vfx-placement-design.md`](./vfx-placement-design.md)。
|
|
6
|
+
|
|
7
|
+
消费模型 = **"模型 C"**:runtime + 类型来自 `@fps/vfx` 包;本地 effect 经 `import.meta.glob` 装配注册表。库走 **"做法A"** 发布:dist 已提交进 git 仓库并打 tag,消费端 `npm install` 直接 clone 拿到成品 dist,**无需 build/registry/postinstall**。
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 1. 消费 `@fps/vfx`(装包 + 构建配置)
|
|
12
|
+
|
|
13
|
+
### 1.1 加依赖(package.json)
|
|
14
|
+
```jsonc
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@babylonjs/core": "8.52.1",
|
|
17
|
+
"@babylonjs/loaders": "8.52.1",
|
|
18
|
+
"@fps/vfx": "git+ssh://git@github.com/forge-play-studio/fps-vfx-library.git#v0.3.0"
|
|
19
|
+
}
|
|
20
|
+
```
|
|
21
|
+
- `#vX.Y.Z` tag 即版本锁。`npm install` 把仓库 clone 进 `node_modules/@fps/vfx`,包的 `exports` 指向已提交的 `dist/index.js` + `dist/index.d.ts` + `dist/effects/*`。
|
|
22
|
+
- **peerDependencies**:`@babylonjs/core ^8` + `@babylonjs/loaders ^8`,项目自带即可。
|
|
23
|
+
|
|
24
|
+
### 1.2 TypeScript(零配置)
|
|
25
|
+
`tsconfig.json` 用 `moduleResolution: "bundler"` 即可解析包的 `exports`/`types`,**不用给 @fps/vfx 配 paths**。
|
|
26
|
+
|
|
27
|
+
### 1.3 Vite(三件事)
|
|
28
|
+
```ts
|
|
29
|
+
// ① Babylon 去重为单实例(否则特效与场景用不同 Babylon → 渲染/类型错乱)
|
|
30
|
+
resolve: { dedupe: ['@babylonjs/core', '@babylonjs/loaders', '@babylonjs/inspector'] },
|
|
31
|
+
|
|
32
|
+
// ② ⭐必做:@fps/vfx 加进构建白名单,否则 `npm run build` 直接 throw
|
|
33
|
+
// (pa_template 用了 build-only 的 thirdPartyWhitelistPlugin;dev/serve 不受影响 → 最易漏)
|
|
34
|
+
const allowedThirdPartyPackages = ['@babylonjs/core', '@babylonjs/loaders', '@fps/vfx', /* ... */];
|
|
35
|
+
|
|
36
|
+
// ③ 单文件打包:dist 含动态 import 的 runtime chunks,这两项把它们内联进单 HTML,无需 copy
|
|
37
|
+
build: { rollupOptions: { output: { inlineDynamicImports: true } } }, // + viteSingleFile()
|
|
38
|
+
```
|
|
39
|
+
**不需要**给 @fps/vfx 配 alias / optimizeDeps / copy —— 走 Vite 默认 node 解析到 dist,dev 与 prod 一致。
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## 2. 装配注册表(模型 C)
|
|
44
|
+
|
|
45
|
+
新建 `src/assets/vfx/index.ts`(barrel,~12 行,新项目几乎一字不改):
|
|
46
|
+
```ts
|
|
47
|
+
// VFX registry barrel —— 消费 @fps/vfx(模型 C)。glob 扫本地 effects 桩;runtime/类型来自包。
|
|
48
|
+
import { buildRegistry, type VfxRegistry } from '@fps/vfx';
|
|
49
|
+
|
|
50
|
+
export * from '@fps/vfx'; // 透传库的全部类型/runtime → 业务侧统一从 '../assets/vfx' 取,不直接 import 包
|
|
51
|
+
|
|
52
|
+
const modules = import.meta.glob('./effects/*/index.ts', { eager: true }) as Record<string, Record<string, unknown>>;
|
|
53
|
+
const params = import.meta.glob('./effects/*/vfx-params.json', { eager: true }) as Record<string, { default: unknown }>;
|
|
54
|
+
|
|
55
|
+
export const VFX_REGISTRY: VfxRegistry = buildRegistry({ modules, params });
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### 2.1 opt-in 一个特效 = 建一个本地桩
|
|
59
|
+
> ⚠️ **glob 只扫本地 `src/assets/vfx/effects/*`;库 dist 里几十个预制特效不会自动进注册表。** 每个要用的特效都得建一个本地桩(哪怕只一行 re-export)。模板出厂 `effects/` 只有 `.gitkeep`,所以 **VFX_REGISTRY 默认是空的**。
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
// src/assets/vfx/effects/thruster-flame/index.ts —— opt-in 库内预制特效(库 exports 子路径 ./effects/*)
|
|
63
|
+
export { thrusterFlameEffectPackage } from '@fps/vfx/effects/thruster-flame';
|
|
64
|
+
```
|
|
65
|
+
- (可选)同目录放 `vfx-params.json`(`{ schemaVersion:'vfx-params/1.0', effectId, updatedAt, params }`)作该特效的 `savedParams` 覆写。
|
|
66
|
+
- 也可在本地桩里写**项目自有特效**(完整 `VfxEffectPackage`,见 AUTHORING_EFFECTS.md)。
|
|
67
|
+
- 坑:两个桩 re-export 同一 `pkg.id` → buildRegistry **直接 throw**(duplicate id)。
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## 3. 接线服务(唯一接线点)
|
|
72
|
+
|
|
73
|
+
`src/services/SceneVfxService.ts`(项目自有薄封装,**不在库里**):
|
|
74
|
+
```ts
|
|
75
|
+
import { EffectPackageService } from '@fps/vfx';
|
|
76
|
+
import { VFX_REGISTRY, type VfxSpawnTransform, type VfxParamValues } from '../assets/vfx';
|
|
77
|
+
|
|
78
|
+
export class SceneVfxService {
|
|
79
|
+
private effectPackageService: EffectPackageService;
|
|
80
|
+
constructor(scene: Scene, options: SceneVfxServiceOptions = {}) {
|
|
81
|
+
this.effectPackageService = new EffectPackageService(scene, {
|
|
82
|
+
registry: VFX_REGISTRY, // ← 注入本地装配的注册表
|
|
83
|
+
getDebugAnchorPosition: options.getDebugAnchorPosition, // ← 调试锚点(可选;不传则锚点跟随不可用)
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
getEffectPackageService() { return this.effectPackageService; }
|
|
87
|
+
playEffectPackage(id: string, params, spawnTransform) { return this.effectPackageService.playEffectPackage(id, params, spawnTransform); }
|
|
88
|
+
dispose() { this.effectPackageService.dispose(); }
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`src/core/Game.ts`(创建 + 暴露门面):
|
|
93
|
+
```ts
|
|
94
|
+
// init():在 entities/zone 之后、gameplay modules 之前创建(gameplay 工厂依赖它非空)
|
|
95
|
+
this.sceneVfxService = new SceneVfxService(this.scene);
|
|
96
|
+
|
|
97
|
+
// 对外播放门面(游戏逻辑/面板都只认这个,不直接碰 EffectPackageService)
|
|
98
|
+
playEffectPackage(id: string, params: Partial<VfxParamValues> = {}, spawnTransform?: VfxSpawnTransform | Vector3) {
|
|
99
|
+
return this.sceneVfxService?.playEffectPackage(id, params, spawnTransform) ?? null;
|
|
100
|
+
}
|
|
101
|
+
getEffectPackageService() { return this.sceneVfxService?.getEffectPackageService() ?? null; }
|
|
102
|
+
|
|
103
|
+
// dispose():this.sceneVfxService?.dispose();
|
|
104
|
+
```
|
|
105
|
+
gameplay 模块经 `createProjectGameplayRuntime({ ..., sceneVfxService })` 注入拿到入口。
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## 4. 播放特效
|
|
110
|
+
|
|
111
|
+
`playEffectPackage(effectId, params, spawnTransform)` 返回 `VfxEffectHandle = { root, ready?, dispose() }`。**生命周期由调用方管**。
|
|
112
|
+
|
|
113
|
+
### 4.1 一次性(one-shot)
|
|
114
|
+
```ts
|
|
115
|
+
const h = game.playEffectPackage('explosion', {}, { position: hitPoint });
|
|
116
|
+
// 自销型特效播完自己消;若要确保清理:
|
|
117
|
+
await h?.ready; /* ...用完... */ h?.dispose();
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### 4.2 持续 / 可重播(单例预览)
|
|
121
|
+
```ts
|
|
122
|
+
previewHandle?.dispose(); // 重播前先 dispose 旧的,否则泄漏多份 root
|
|
123
|
+
previewHandle = game.playEffectPackage(id, params);
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### 4.3 放置(placement)——**需 @fps/vfx ≥ v0.2.0**
|
|
127
|
+
> v0.1.1 的 `spawnTransform` 只有 `{ position?, rotationY?, renderingGroupId? }`(世界坐标老式范式)。
|
|
128
|
+
> PR #12 合并发版后,`spawnTransform` 扩展为下面这套,并按特效的 `placement` 标记放置:
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
type VfxSpawnTransform = {
|
|
132
|
+
position?, rotationY?, rotation?, scale?, renderingGroupId?,
|
|
133
|
+
parent?: TransformNode, // 挂载点:特效 root parent 到它 → 经场景图自动跟随
|
|
134
|
+
offsetIsLocal?: boolean,
|
|
135
|
+
inputs?: { // 运行时外部信息(瞄准点/法线/端点/目标);值可静态或 provider(动态每帧取)
|
|
136
|
+
aim?, normal?, endpoints?, target?, scalars?
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
按特效的 `placement`(socket / world / link / target / screen,见 vfx-placement-design.md):
|
|
142
|
+
```ts
|
|
143
|
+
// socket:挂到对象的命名挂点,随它走
|
|
144
|
+
game.playEffectPackage('thruster-flame', {}, { parent: engineSocket, offsetIsLocal: true });
|
|
145
|
+
|
|
146
|
+
// world:在运行时算出的世界点 spawn,不跟随(命中/爆炸/落点)
|
|
147
|
+
game.playEffectPackage('monster-hit', {}, { position: hitPoint });
|
|
148
|
+
|
|
149
|
+
// 定向武器(laser/pulse):socket 起点 + inputs.aim 终点(provider 追移动目标)
|
|
150
|
+
game.playEffectPackage('laser-beam', {}, {
|
|
151
|
+
parent: muzzle, offsetIsLocal: true,
|
|
152
|
+
inputs: { aim: () => enemy.getAbsolutePosition() }, // 动态目标每帧取
|
|
153
|
+
});
|
|
154
|
+
```
|
|
155
|
+
> ⚠️ v0.1.1(及之前)**没有 parent 挂载**。要让特效跟随移动目标,只能自己持有 handle、每帧手写 `handle.root.position = target.position`。升级到 ≥v0.2.0 后改用 `parent`(场景图自动跟随),并用 `inputs.aim` 取代手动追瞄。
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## 5. 调试面板(可选)
|
|
160
|
+
```ts
|
|
161
|
+
// runtime-debug-bootstrap.ts
|
|
162
|
+
runtimePanels.use(mountRuntimeVfxDebugPanel({ root, getGame }));
|
|
163
|
+
```
|
|
164
|
+
- 面板经 `getGame().getEffectPackageService().getEffectPackages()` 拉特效列表;`getMergedParams(id)` 拉合并默认值填草稿。
|
|
165
|
+
- 保存:POST dev server `/__vfx_debug_overrides {effectId, params}` → 落盘 `src/assets/vfx/effects/<id>/vfx-params.json` + `setDebugOverride(id, params)` 即时生效后 reload(生产无此路由)。
|
|
166
|
+
- VFX_REGISTRY 为空时面板自动隐藏(列表为空)。
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## 6. 升级 @fps/vfx 版本
|
|
171
|
+
1. **库侧(做法A)**:`pnpm release -- <patch|minor|major>` 一条龙(gate → bump → CHANGELOG → 提交 dist → tag → push)。
|
|
172
|
+
2. **消费侧**:改 `package.json` 的 `#vX.Y.Z` → `npm install`(git 依赖有时需先删 `node_modules/@fps/vfx` 再装才 clone 到新 tag)→ `npm run build` 验白名单与打包。
|
|
173
|
+
3. 详见 `vfx-library-maintenance` skill。
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
## 7. 常见坑(checklist)
|
|
178
|
+
- [ ] **`@fps/vfx` 没进 vite 白名单** → dev 能跑、`npm run build` 直接 throw(最易漏)。
|
|
179
|
+
- [ ] **Babylon 没 dedupe** → 特效与场景用不同 Babylon 实例,渲染/类型错乱。
|
|
180
|
+
- [ ] **以为装了包就有特效** → 错,glob 只扫本地 `effects/*`,每个特效都要建本地桩。
|
|
181
|
+
- [ ] **两个桩 re-export 同一 id** → buildRegistry throw。
|
|
182
|
+
- [ ] **业务侧直接 import '@fps/vfx' 类型** → 应统一从 `'../assets/vfx'`(barrel `export *` 透传),保持单一出口。
|
|
183
|
+
- [ ] **持续特效不 dispose 旧 handle 就重播** → 泄漏多份 root。
|
|
184
|
+
- [ ] **想要挂载/瞄准却在旧版** → 升级到 ≥v0.2.0。
|
|
185
|
+
|
|
186
|
+
## 8. 参考真实文件(pa_template)
|
|
187
|
+
| 切面 | 文件 |
|
|
188
|
+
|---|---|
|
|
189
|
+
| 依赖 | `package.json`(`@fps/vfx` git tag) |
|
|
190
|
+
| vite | `vite.config.ts`(dedupe / 白名单 / inlineDynamicImports) |
|
|
191
|
+
| 注册表 | `src/assets/vfx/index.ts` + `effects/<id>/index.ts` 桩 |
|
|
192
|
+
| 服务接线 | `src/services/SceneVfxService.ts` + `src/core/Game.ts` |
|
|
193
|
+
| 播放范式 | `SceneVfxService.warmupEffectPackage`(one-shot)、`runtime-vfx-debug-panel.ts playPreviewNow`(可重播) |
|
|
194
|
+
| 调试面板 | `src/debug/runtime-vfx-debug-panel.ts` |
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# 方法:物体受击闪红(Hit Flash)
|
|
2
|
+
|
|
3
|
+
> 给「**被击中的那个物体**」加一个受击瞬间染红 / 保持 / 淡回的反馈。供 coding agent 直接套用。
|
|
4
|
+
> 移植自 axe_and_bear 的 `HitFlashService`(熊被斧子打到时整只变红)。
|
|
5
|
+
|
|
6
|
+
## 它和普通特效的本质区别 —— 先读这段
|
|
7
|
+
|
|
8
|
+
- 普通 VFX(爆炸、拖尾、命中爆闪)是 **在某个位置 spawn 一团东西**(粒子/网格),和被打的物体无关。
|
|
9
|
+
- 受击闪红是 **修改被打物体自己的材质颜色**(`emissiveColor`)。它没有"自己的网格",寄生在目标身上。
|
|
10
|
+
|
|
11
|
+
所以它**不是 `@fps/vfx` 的 `VfxEffectPackage`**(那个契约是 `create({position}) → 在该点生成`)。
|
|
12
|
+
不要试图 `vfx add hit-flash`。正确做法是把下面这个工具类放进**宿主工程**,在你能拿到目标 mesh 的地方用它。
|
|
13
|
+
|
|
14
|
+
## 原理(一句话)
|
|
15
|
+
|
|
16
|
+
每个目标 mesh:**克隆一份材质**(防止污染共享材质)→ 存下原始 `emissiveColor` → 受击时把 emissive 插值到红色,`holdTime` 秒保持全红,`fadeTime` 秒线性淡回原色。
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
play() ┌─ holdTime ─┐┌──── fadeTime ────┐
|
|
20
|
+
红度 1.0 ████████████░░░░░░░░░░░░░░░░░░░░ → 回到原 emissive
|
|
21
|
+
命中 结束
|
|
22
|
+
```
|
|
23
|
+
红度 `t` → `emissive = Lerp(原色, Lerp(原色, 红, flashStrength), t)`
|
|
24
|
+
|
|
25
|
+
## 完整实现(复制即用)
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
// hit-flash.ts —— 放进宿主工程(不是 @fps/vfx)
|
|
29
|
+
import { Color3 } from '@babylonjs/core/Maths/math.color';
|
|
30
|
+
import type { AbstractMesh } from '@babylonjs/core/Meshes/abstractMesh';
|
|
31
|
+
import type { Material } from '@babylonjs/core/Materials/material';
|
|
32
|
+
import type { Scene } from '@babylonjs/core/scene';
|
|
33
|
+
import type { Observer } from '@babylonjs/core/Misc/observable';
|
|
34
|
+
|
|
35
|
+
export interface HitFlashOptions {
|
|
36
|
+
flashColor?: Color3; // 闪光色,默认红 (1,0,0)
|
|
37
|
+
flashStrength?: number; // 0..1,红色混入强度(1=完全替换),默认 1
|
|
38
|
+
holdTime?: number; // 全红保持秒,默认 0.2
|
|
39
|
+
fadeTime?: number; // 淡回原色秒,默认 0.1
|
|
40
|
+
emissiveBoost?: number; // HDR 倍率:有 bloom/tonemapping 时调大(默认 3.4);无后处理用 1
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface FlashEntry { mat: Material & { emissiveColor?: Color3 }; original: Color3; }
|
|
44
|
+
|
|
45
|
+
export class HitFlash {
|
|
46
|
+
private entries: FlashEntry[] = [];
|
|
47
|
+
private readonly flashColor: Color3;
|
|
48
|
+
private readonly flashStrength: number;
|
|
49
|
+
private readonly holdTime: number;
|
|
50
|
+
private readonly fadeTime: number;
|
|
51
|
+
private elapsed = 0;
|
|
52
|
+
private total = 0;
|
|
53
|
+
private observer: Observer<Scene> | null = null;
|
|
54
|
+
|
|
55
|
+
/** meshes:目标物体的所有可见 mesh(通常 root.getChildMeshes())。一个目标建一次,反复 play()。 */
|
|
56
|
+
constructor(meshes: AbstractMesh[], private readonly scene: Scene, opts: HitFlashOptions = {}) {
|
|
57
|
+
this.flashColor = (opts.flashColor ?? new Color3(1, 0, 0)).scale(opts.emissiveBoost ?? 3.4);
|
|
58
|
+
this.flashStrength = opts.flashStrength ?? 1;
|
|
59
|
+
this.holdTime = opts.holdTime ?? 0.2;
|
|
60
|
+
this.fadeTime = opts.fadeTime ?? 0.1;
|
|
61
|
+
for (const mesh of meshes) {
|
|
62
|
+
const src = mesh.material;
|
|
63
|
+
if (!src || !('emissiveColor' in src)) continue;
|
|
64
|
+
// 关键:克隆材质,否则会染红所有共用此材质的物体
|
|
65
|
+
const cloned = (src.clone(`${src.name}_hitflash`) ?? src) as FlashEntry['mat'];
|
|
66
|
+
mesh.material = cloned;
|
|
67
|
+
this.entries.push({ mat: cloned, original: (cloned.emissiveColor ?? Color3.Black()).clone() });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 每次受击调用。连续受击会刷新到峰值(不叠加爆掉)。 */
|
|
72
|
+
play(): void {
|
|
73
|
+
this.elapsed = 0;
|
|
74
|
+
this.total = this.holdTime + this.fadeTime;
|
|
75
|
+
if (!this.observer) {
|
|
76
|
+
this.observer = this.scene.onBeforeRenderObservable.add(() => this.update());
|
|
77
|
+
}
|
|
78
|
+
this.apply(1);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private update(): void {
|
|
82
|
+
this.elapsed += this.scene.getEngine().getDeltaTime() / 1000;
|
|
83
|
+
if (this.elapsed <= this.holdTime) {
|
|
84
|
+
this.apply(1);
|
|
85
|
+
} else if (this.elapsed < this.total) {
|
|
86
|
+
this.apply(Math.max(0, 1 - (this.elapsed - this.holdTime) / this.fadeTime));
|
|
87
|
+
} else {
|
|
88
|
+
this.apply(0);
|
|
89
|
+
this.detach();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private apply(t: number): void {
|
|
94
|
+
for (const e of this.entries) {
|
|
95
|
+
const target = Color3.Lerp(e.original, this.flashColor, this.flashStrength);
|
|
96
|
+
e.mat.emissiveColor = Color3.Lerp(e.original, target, t);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private detach(): void {
|
|
101
|
+
if (this.observer) { this.scene.onBeforeRenderObservable.remove(this.observer); this.observer = null; }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 目标销毁前调用:恢复原始 emissive + 移除 observer。 */
|
|
105
|
+
dispose(): void {
|
|
106
|
+
this.detach();
|
|
107
|
+
for (const e of this.entries) e.mat.emissiveColor = e.original;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## 用法
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
// 1) 目标出生时建一次(传它所有可见 mesh)
|
|
116
|
+
const flash = new HitFlash(bear.getChildMeshes(), scene, {
|
|
117
|
+
flashColor: new Color3(1, 0, 0), // 红
|
|
118
|
+
holdTime: 0.2, fadeTime: 0.1,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// 2) 每次被打调用
|
|
122
|
+
function onBearHit() {
|
|
123
|
+
bear.subHp(dmg);
|
|
124
|
+
flash.play(); // ← 闪红
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 3) 目标死亡/移除前
|
|
128
|
+
flash.dispose();
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## 参数怎么调影响什么
|
|
132
|
+
|
|
133
|
+
| 参数 | 影响 |
|
|
134
|
+
|---|---|
|
|
135
|
+
| `flashColor` | 闪光颜色。红=受伤;白=普通受击;绿=治疗/buff |
|
|
136
|
+
| `flashStrength` | 红色混入比例。1=峰值完全红;0.5=半红(保留原色感) |
|
|
137
|
+
| `holdTime` | 全红保持时长。越大越"顿挫"/越重 |
|
|
138
|
+
| `fadeTime` | 淡回时长。越大余韵越长越软 |
|
|
139
|
+
| `emissiveBoost` | HDR 倍率。有 bloom/tonemapping 时调大让红更亮更艳(线性空间);无后处理设 1 |
|
|
140
|
+
|
|
141
|
+
## 必须注意的坑(coding agent 重点看)
|
|
142
|
+
|
|
143
|
+
1. **一定要克隆材质**。直接改 `mesh.material.emissiveColor` 会染红**所有共用这份材质的物体**(整片同模型一起变红)。上面构造里已 `clone()`。
|
|
144
|
+
2. **用 `emissiveColor` 不是 `diffuseColor`/`albedoColor`**。自发光不受光照影响、像"发光"般醒目,且能吃 bloom;`StandardMaterial` 和 `PBRMaterial` 都有 `emissiveColor`。
|
|
145
|
+
3. **存原色 + dispose 恢复**。目标原本可能就有 emissive(发光怪),必须存原值并在 dispose 时还原,否则残留。
|
|
146
|
+
4. **重复受击**。`play()` 重置计时到峰值 → 连击是"刷新红光"而非叠加,不会爆白。
|
|
147
|
+
5. **observer 生命周期**。淡出结束自动 `detach()`;目标销毁前务必 `dispose()` 移除残留 observer,避免泄漏与"对着已销毁材质写值"。
|
|
148
|
+
6. **PBR + emissiveTexture**:若目标材质带自发光贴图,纯色 emissive 会与贴图相加;受击闪红一般可接受,介意的话可在 play 时临时把 `emissiveTexture` 置 null、dispose 时还原。
|
|
149
|
+
7. **蒙皮/多子网格**:用 `root.getChildMeshes()` 把所有子网格都纳入,漏掉的部位不会变红。
|
|
150
|
+
|
|
151
|
+
## 可选增强
|
|
152
|
+
|
|
153
|
+
- **配合受击粒子**:闪红是"染目标",再在命中点叠一个 `@fps/vfx` 的 `monster-hit`(闪光+血)burst,就是完整受击反馈——两者正交,各管一层。
|
|
154
|
+
- **击退/缩放抖动**:`play()` 时顺带给 root 一个轻微 scale punch(1.1→1.0)或位移,手感更冲。
|
|
155
|
+
- **死亡白闪**:把 `flashColor` 设白、`holdTime/fadeTime` 拉长,即死亡瞬间的泛白。
|