@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
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# VFX 放置系统设计(定稿)
|
|
2
|
+
|
|
3
|
+
> 把"特效怎么被放置/触发"从隐式实现升为**显式契约**。结论来自 #9 的挂载/attach 设计 + "只挂点是否充分"的对抗式反证(#9 评论)。
|
|
4
|
+
> 核心裁决:**挂点(socket)是默认主路径,但不是唯一**;每个特效用一个 `placement` 标记声明它的放置本质,binding 按标记提供对应输入。
|
|
5
|
+
|
|
6
|
+
## 1. 核心原则
|
|
7
|
+
|
|
8
|
+
1. **放置基元是 position**(必要时 + 法线 / + 第二端点),`socket-follow` 只是其中一个特例;**不要反过来把 position 当"挂点的壳"**。
|
|
9
|
+
2. **每个特效声明 `placement` 标记**(它的放置本质,内禀属性)。系统/binding/面板**读标记决定怎么放**,不靠猜。
|
|
10
|
+
3. **socket 与动态坐标都只是"位置来源"**:一个来自数据(prefab 上的命名挂点),一个来自触发事件(运行时算出的世界坐标)。Director 用同一套"取位置 → 放特效",只是取法不同。
|
|
11
|
+
4. **错误挡在运行时之前**:placement 与 binding 的匹配在构建/编辑期校验(guardrail),不到运行时才炸。
|
|
12
|
+
|
|
13
|
+
## 2. `placement` 标记(特效级,内禀)
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
type VfxPlacement =
|
|
17
|
+
| 'socket' // local:挂对象的命名挂点、随它走(parent + follow)
|
|
18
|
+
| 'world' // global:放在世界某点、不跟随(fire-and-forget,可带法线定向)
|
|
19
|
+
| 'screen' // global:屏幕/相机层(后处理 pass / 相机扰动 / DOM 全屏),无 3D transform
|
|
20
|
+
| 'target' // 改目标自身材质/渲染态(给 targetMesh + 时序),没有可 spawn 的 root
|
|
21
|
+
| 'link'; // 连 ≥2 个端点(每帧重建几何),单 parent 表达不了
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### `socket` 的朝向子类(创作/集成需区分)
|
|
25
|
+
|
|
26
|
+
挂点不仅是"位置",对部分特效**朝向也是语义**:
|
|
27
|
+
|
|
28
|
+
| 子类 | 朝向是否关键 | 还需目标? | 例 |
|
|
29
|
+
|---|---|---|---|
|
|
30
|
+
| **位置型** | 否(只要位置) | 否 | energy-shield、各 upgrade 光环、跟随 DoT |
|
|
31
|
+
| **朝向型** | 是(挂点 +Z/-Z = 喷射方向) | 否 | thruster-flame(+Z 朝机尾)、ufo-thread-flame(-Z)、comet-trail |
|
|
32
|
+
| **定向武器** | 是(挂点 +Z = 发射方向) | **是**(炮口朝目标) | pulse-laser-volley(沿 +Z 齐射)、laser-beam(link,A→B) |
|
|
33
|
+
|
|
34
|
+
→ 集成/测试时:朝向型必须把挂点朝向摆对;定向武器还要在场景里给一个**目标**,炮口朝它(否则乱喷)。`laser-beam` 因有真实第二端点归 `link`;`pulse-laser-volley` 是"socket + 发射方向",目标只决定方向/射程(targetDistance),不追踪第二实体。
|
|
35
|
+
|
|
36
|
+
辅助标志(不是第六种空间,而是 socket/world 的机制修饰):
|
|
37
|
+
```ts
|
|
38
|
+
interface VfxPlacementMeta {
|
|
39
|
+
placement: VfxPlacement;
|
|
40
|
+
// 几何空间:'local'=挂 root 子节点随它刚性变换;'world'=几何用世界坐标、root 仅作每帧采样源(拖尾)
|
|
41
|
+
geometrySpace?: 'local' | 'world';
|
|
42
|
+
// 实例化宿主要挂真 spawn 特效时:'matrix-follow'=建跟随 instance world matrix 的临时锚;'de-instance'=先物化成 standalone mesh
|
|
43
|
+
instancing?: 'none' | 'matrix-follow' | 'de-instance';
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
| 标记 | local/global | 需要挂点 | 怎么放 | 典型特效 |
|
|
48
|
+
|---|---|---|---|---|
|
|
49
|
+
| `socket` | local | ✅ 一个挂点 | parent 到 socket,场景图跟随 | thruster-flame、energy-shield、弹体拖尾、跟随敌人的 DoT |
|
|
50
|
+
| `world` | global | ❌ 一个世界坐标 | 在该点 spawn、不跟随、自销;可按法线定向 | monster-hit、explosion、run-footstep-dust、落点圈、telegraph |
|
|
51
|
+
| `screen` | global | ❌ | 后处理参数 / 相机扰动 / DOM 全屏 / worldToScreen | vignette、死亡白屏闪、镜头抖、金币飞 HUD |
|
|
52
|
+
| `target` | — | ❌ 一个目标 mesh | 改宿主自身材质 emissive/渲染态(tween) | hit-flash(染红)、dissolve、freeze、区域高亮 |
|
|
53
|
+
| `link` | global | ❌ ≥2 端点 | 每帧按两端重建几何 | laser-beam(A→B)、链式闪电、resource-collect(from→target)、tether |
|
|
54
|
+
|
|
55
|
+
## 3. 位置来源(binding 级)
|
|
56
|
+
|
|
57
|
+
binding 不假定一个 `socket`,而是显式声明位置来源:
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
type VfxPositionSource =
|
|
61
|
+
| { kind: 'socket'; target: string; socket: string } // 静态:prefab 上命名挂点(随对象走)
|
|
62
|
+
| { kind: 'event'; field: string } // 动态:从触发事件 ctx 取世界坐标
|
|
63
|
+
| { kind: 'world'; position: Vec3 } // 固定世界坐标(静态场景特效)
|
|
64
|
+
| { kind: 'link'; from: VfxPositionSource; to: VfxPositionSource } // 双端点(各可 socket/event)
|
|
65
|
+
| { kind: 'screen' } // 无 3D 位置
|
|
66
|
+
| { kind: 'target'; field: string }; // 目标 mesh(从 ctx 取)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**动态坐标类(`world`)的标准处理**:位置由**触发事件携带**(检测到事件的游戏逻辑本就算出来了),不存 socket、不建锚点:
|
|
70
|
+
```ts
|
|
71
|
+
// 游戏逻辑(已有命中点):
|
|
72
|
+
vfx.emit('enemy.hit', { at: hitPoint, normal: hitNormal });
|
|
73
|
+
```
|
|
74
|
+
```jsonc
|
|
75
|
+
// binding:
|
|
76
|
+
{ "trigger": "enemy.hit", "effect": "monster-hit",
|
|
77
|
+
"placement": "world",
|
|
78
|
+
"positionSource": { "kind": "event", "field": "at" },
|
|
79
|
+
"alignTo": "normal", // 可选:按法线定向(贴花/冲击波)
|
|
80
|
+
"lifecycle": "oneshot" } // 播完自销,不 parent → 零锚点生命周期 → 不会泄漏
|
|
81
|
+
```
|
|
82
|
+
→ **不 parent、不建临时锚点**,这是最简、最不易 bug 的情形(不是最复杂)。
|
|
83
|
+
|
|
84
|
+
## 3.5 inputs 通道(运行时外部信息)
|
|
85
|
+
|
|
86
|
+
`position`/`rotation` 解决"挂哪、朝哪",但**激光的瞄准点、贴花的法线、链式的多端点、染红的目标 mesh** 这类**运行时外部信息**装不进 position+params。把三者分开:
|
|
87
|
+
|
|
88
|
+
| | 是什么 | 谁提供 | 何时定 |
|
|
89
|
+
|---|---|---|---|
|
|
90
|
+
| placement/transform | 挂哪、位置、朝向 | 挂点/事件 | 放置时 |
|
|
91
|
+
| **params** | 可调外观旋钮(颜色/长度/速度) | 美术 | **设计期**,存 vfx-params.json |
|
|
92
|
+
| **inputs** | 运行时外部上下文(瞄准点/法线/端点/目标) | **游戏逻辑** | **运行时**,常**动态** |
|
|
93
|
+
|
|
94
|
+
> ⚠️ laser 现把 `targetDistance` 塞进 params 是 hack——真瞄准点是运行时外部信息,应迁到 `inputs.aim`。
|
|
95
|
+
|
|
96
|
+
**播放接口**(签名不变,spawn 里加 inputs 通道):
|
|
97
|
+
```ts
|
|
98
|
+
playEffectPackage(id, params, {
|
|
99
|
+
parent?, position?, rotation?, scale?, offsetIsLocal?, // 放置
|
|
100
|
+
inputs?: { // 外部信息
|
|
101
|
+
aim?: Vector3 | (() => Vector3); // 瞄准点(激光/定向)
|
|
102
|
+
normal?: Vector3 | (() => Vector3); // 表面法线(贴花/冲击)
|
|
103
|
+
endpoints?: Array<Vector3 | (() => Vector3)>; // link/链式(≥2 端点)
|
|
104
|
+
target?: AbstractMesh; // target-material 类
|
|
105
|
+
},
|
|
106
|
+
})
|
|
107
|
+
```
|
|
108
|
+
- 特效契约声明 `requiredInputs: ('aim'|'normal'|'endpoints'|'target')[]` → **构建期校验**(link 必须给 endpoints/aim;target 类必须给 target mesh;漏了即红)。
|
|
109
|
+
- **`value | (() => value)`**:静态值 or provider——移动目标用 provider **每帧追**。
|
|
110
|
+
- create() 收到的 inputs 已**归一化为访问器**(`inputs.aim()` 总可调用)。
|
|
111
|
+
- 词汇表**小而固定**(覆盖 §2 的 5 模式),不是任意 blob → 通用 + 类型安全。对齐引擎:Niagara *User Parameters* / VFX Graph *Exposed Properties* / GAS *GameplayCueParameters*。
|
|
112
|
+
|
|
113
|
+
**laser 迁移示例**:
|
|
114
|
+
```ts
|
|
115
|
+
// 现状 hack:只能朝正前方固定距离
|
|
116
|
+
beamRoot.scaling.z = params.targetDistance;
|
|
117
|
+
// 迁移后:placement:'link', requiredInputs:['aim']
|
|
118
|
+
create({ root, params, inputs }) {
|
|
119
|
+
scene.onBeforeRenderObservable.add(() => {
|
|
120
|
+
const tgt = inputs.aim(); // 每帧取(可动态)
|
|
121
|
+
beamRoot.scaling.z = tgt.subtract(root.getAbsolutePosition()).length(); // 束长=到目标距离
|
|
122
|
+
root.lookAt(tgt); // 朝目标
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
// 调用:静态 { inputs:{ aim: towerPos } } / 动态 { inputs:{ aim: () => enemy.getWorldPosition() } }
|
|
126
|
+
```
|
|
127
|
+
**契约改动(已实现)**:`VfxSpawnTransform.inputs`、`VfxEffectPackageContext.inputs`(归一化访问器)、`VfxEffectPackage.requiredInputs`。首批迁移:laser-beam、pulse-laser-volley。
|
|
128
|
+
|
|
129
|
+
## 4. Binding schema(Director 读它 spawn)
|
|
130
|
+
|
|
131
|
+
```jsonc
|
|
132
|
+
{
|
|
133
|
+
"trigger": "enemy.hit", // 语义事件(或 entity.spawned 等生命周期)
|
|
134
|
+
"effect": "monster-hit", // 特效 id
|
|
135
|
+
"placement": "world", // 与特效的 placement 标记一致(校验)
|
|
136
|
+
"positionSource": { "kind": "event", "field": "at" },
|
|
137
|
+
"params": { "bloodCount": 24 }, // 该实例的参数(override,见多实例)
|
|
138
|
+
"lifecycle": "oneshot", // oneshot 自销 / follow 随宿主生灭
|
|
139
|
+
"alignTo": "normal" // 可选
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
多实例(一对多)= 多条 binding 或一条带 instances 数组,每条带自己的 positionSource + params(见 #9 评论"每实例独立参数")。
|
|
143
|
+
|
|
144
|
+
## 5. 校验规则(guardrail,构建/编辑期)
|
|
145
|
+
|
|
146
|
+
- `placement: 'socket'` → positionSource 必须是 `socket`(或 `event` 提供 node);否则报错。
|
|
147
|
+
- `placement: 'world'` → positionSource 必须给得出世界坐标(`event`/`world`/`socket`)。
|
|
148
|
+
- `placement: 'link'` → positionSource 必须是 `link`(两端齐全)。
|
|
149
|
+
- `placement: 'screen'/'target'` → 不得有 socket/position;`target` 必须给 targetMesh 来源。
|
|
150
|
+
- 每条 binding 的 `effect` id、`socket` 名(对真实 prefab)、`trigger` 名都校验存在 → 拼错/挂点删了**构建即红**。
|
|
151
|
+
- trigger / effect id 用生成的联合类型,编译器先挡一道。
|
|
152
|
+
|
|
153
|
+
## 6. 落地分期
|
|
154
|
+
|
|
155
|
+
- **P0**(已在 feat/spawn-attach-p0 / PR #10):完整旋转+缩放+可选挂载 spawn transform;双偏移修复(root 摆放归 service 独占)。
|
|
156
|
+
- **P1**(本分支):给契约加 `placement` 标记 + 给现有特效标注;`world` 类的"事件携带坐标 + 自销"路径;校验规则。
|
|
157
|
+
- **P2**:Director + binding 表(声明式 trigger→effect→placement→params);socket/de-instance 解析层;`link`/`screen`/`target` 的放置实现。
|
|
158
|
+
- **P3**:调试面板按 placement 标记路由 + 每特效真实 GLB 测试场景。
|
|
159
|
+
|
|
160
|
+
## 7. 反模式(别做)
|
|
161
|
+
1. 别把世界空间拖尾/动态命中点/双锚点/屏幕后处理/改材质**硬塞进挂点模型**——撑破模型、把 bug 赶进运行时临时锚点。
|
|
162
|
+
2. `world` 类**别建临时锚点节点**——直接在事件携带的坐标 spawn、自销,零锚点。
|
|
163
|
+
3. 别让每个 adapter 各自实现放置——收敛进 Director + 解析层。
|
|
164
|
+
4. 别运行时热翻转 placement——创作期决策。
|
package/index.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": "vfx-index/1.0",
|
|
3
|
-
"count":
|
|
3
|
+
"count": 37,
|
|
4
4
|
"effects": [
|
|
5
5
|
{
|
|
6
6
|
"id": "build.particle.lumber_debris_scatter",
|
|
@@ -267,6 +267,24 @@
|
|
|
267
267
|
"media": {},
|
|
268
268
|
"path": "packages/effects/thruster-flame"
|
|
269
269
|
},
|
|
270
|
+
{
|
|
271
|
+
"id": "treasure-attention",
|
|
272
|
+
"dir": "treasure-attention",
|
|
273
|
+
"nameZh": "宝藏注意力光效",
|
|
274
|
+
"kind": "code",
|
|
275
|
+
"category": "treasure-attention",
|
|
276
|
+
"tags": [
|
|
277
|
+
"treasure",
|
|
278
|
+
"attention",
|
|
279
|
+
"pickup",
|
|
280
|
+
"gold",
|
|
281
|
+
"sparkle",
|
|
282
|
+
"aura",
|
|
283
|
+
"ring"
|
|
284
|
+
],
|
|
285
|
+
"media": {},
|
|
286
|
+
"path": "packages/effects/treasure-attention"
|
|
287
|
+
},
|
|
270
288
|
{
|
|
271
289
|
"id": "tree_chop_dust_burst",
|
|
272
290
|
"dir": "tree_chop_dust_burst",
|
package/package.json
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fps-games/vfx",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.2",
|
|
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
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
6
9
|
"types": "./dist/index.d.ts",
|
|
7
10
|
"module": "./dist/index.js",
|
|
8
11
|
"exports": {
|
|
@@ -38,6 +41,7 @@
|
|
|
38
41
|
"test:guardrails": "node tooling/test-guardrails.mjs",
|
|
39
42
|
"generate:index": "node tooling/generate-index.mjs",
|
|
40
43
|
"demo:energy-shield": "vite demo/energy-shield --host 127.0.0.1",
|
|
44
|
+
"demo:treasure-attention": "vite demo/treasure-attention --host 127.0.0.1",
|
|
41
45
|
"release": "node scripts/release.mjs"
|
|
42
46
|
},
|
|
43
47
|
"//": "分发=git tag + 提交 dist(做法A):发版前 pnpm build → 提交 dist/ → git tag。消费端 import '@fps/vfx' 解析到 dist;本地开发可用 FPS_VFX_LIBRARY_REPO 切回源码 alias。",
|
|
@@ -7,9 +7,12 @@ import type {
|
|
|
7
7
|
VfxColorValue,
|
|
8
8
|
VfxDebugAnchor,
|
|
9
9
|
VfxEffectHandle,
|
|
10
|
+
VfxEffectInputs,
|
|
10
11
|
VfxEffectPackage,
|
|
12
|
+
VfxInputKey,
|
|
11
13
|
VfxParamValue,
|
|
12
14
|
VfxParamValues,
|
|
15
|
+
VfxResolvedInputs,
|
|
13
16
|
VfxSpawnParamKeys,
|
|
14
17
|
VfxSpawnTransform,
|
|
15
18
|
} from './types';
|
|
@@ -85,20 +88,28 @@ export class EffectPackageService {
|
|
|
85
88
|
if (!params) return null;
|
|
86
89
|
|
|
87
90
|
const spawn = this.resolveSpawnTransform(effectPackage, params, spawnTransform);
|
|
91
|
+
warnMissingRequiredInputs(effectId, effectPackage.requiredInputs, spawn.inputs);
|
|
88
92
|
const runtimeParams = stripSpawnParams(params, effectPackage.spawnParams);
|
|
89
93
|
const root = new TransformNode(`vfx_package_${effectId}`, this.scene);
|
|
90
|
-
root
|
|
91
|
-
|
|
94
|
+
applySpawnToRoot(root, spawn);
|
|
95
|
+
// 世界位置(挂到 parent 后即为绝对世界坐标):保证世界空间特效(如拖尾)起点正确。
|
|
96
|
+
const worldPosition = root.getAbsolutePosition().clone();
|
|
92
97
|
let handle: VfxEffectHandle;
|
|
93
98
|
try {
|
|
94
99
|
handle = effectPackage.create({
|
|
95
100
|
scene: this.scene,
|
|
96
101
|
root,
|
|
97
|
-
position:
|
|
102
|
+
position: worldPosition,
|
|
98
103
|
rotationY: spawn.rotationY,
|
|
99
104
|
renderingGroupId: spawn.renderingGroupId,
|
|
100
105
|
params: runtimeParams,
|
|
106
|
+
scale: spawn.scale,
|
|
107
|
+
inputs: spawn.inputs,
|
|
101
108
|
});
|
|
109
|
+
// root 的摆放归 service 独占。许多特效在 create() 里会写 `root.position.copyFrom(position)`
|
|
110
|
+
// (用世界坐标),挂到 parent 时会与 local offset 冲突 → 双偏移。这里在 create() 之后
|
|
111
|
+
// 重新断言一次 root transform,覆盖回 spawn 的正确值(无 parent 时为同值,无副作用)。
|
|
112
|
+
applySpawnToRoot(root, spawn);
|
|
102
113
|
} catch (error) {
|
|
103
114
|
// 错误兜底:单个坏包不得崩掉整个场景(plan §7)
|
|
104
115
|
console.warn(`[EffectPackageService] effect "${effectId}" create() failed; skipped.`, error);
|
|
@@ -111,6 +122,9 @@ export class EffectPackageService {
|
|
|
111
122
|
this.activeHandles.delete(handle);
|
|
112
123
|
originalDispose();
|
|
113
124
|
};
|
|
125
|
+
// G4/M4:很多特效内部用 scheduleLifetime 自销,直接 dispose root(绕过上面 wrap 的 handle.dispose),
|
|
126
|
+
// 会让 activeHandles 残留已死 handle。监听 root 销毁 → 任何路径销毁都清理(自销/手动皆覆盖)。
|
|
127
|
+
handle.root.onDisposeObservable.addOnce(() => { this.activeHandles.delete(handle); });
|
|
114
128
|
return handle;
|
|
115
129
|
}
|
|
116
130
|
|
|
@@ -134,27 +148,119 @@ export class EffectPackageService {
|
|
|
134
148
|
effectPackage: VfxEffectPackage,
|
|
135
149
|
params: VfxParamValues,
|
|
136
150
|
spawnTransform?: VfxSpawnTransform | Vector3,
|
|
137
|
-
):
|
|
151
|
+
): ResolvedSpawnTransform {
|
|
138
152
|
const normalized = normalizeSpawnTransform(spawnTransform);
|
|
139
|
-
const
|
|
140
|
-
|
|
153
|
+
const parent = normalized.parent ?? null;
|
|
154
|
+
// 有 parent(挂载)时默认落在挂点 (0,0,0) local;无 parent 时才回退到 debug 锚点(预览用)。
|
|
155
|
+
const basePosition = normalized.position?.clone()
|
|
156
|
+
?? (parent != null ? new Vector3(0, 0, 0) : this.resolveDebugPosition(effectPackage));
|
|
157
|
+
const rotation = resolveRotation(normalized.rotation, normalized.rotationY);
|
|
158
|
+
const scale = resolveScale(normalized.scale);
|
|
159
|
+
// 默认坐标系:给了 parent → local;没给 → 世界(保持现状)。
|
|
160
|
+
const offsetIsLocal = normalized.offsetIsLocal ?? parent != null;
|
|
141
161
|
const renderingGroupId = readOptionalFiniteNumber(normalized.renderingGroupId);
|
|
142
162
|
const offset = readSpawnOffset(params, effectPackage.spawnParams);
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
return {
|
|
149
|
-
position: basePosition.addInPlace(new Vector3(
|
|
163
|
+
let position = basePosition;
|
|
164
|
+
if (offset.lengthSquared() > 0.0000001) {
|
|
165
|
+
const cos = Math.cos(rotation.y);
|
|
166
|
+
const sin = Math.sin(rotation.y);
|
|
167
|
+
position = basePosition.addInPlace(new Vector3(
|
|
150
168
|
offset.x * cos - offset.z * sin,
|
|
151
169
|
offset.y,
|
|
152
170
|
offset.x * sin + offset.z * cos,
|
|
153
|
-
))
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
171
|
+
));
|
|
172
|
+
}
|
|
173
|
+
return { position, rotation, rotationY: rotation.y, scale, parent, offsetIsLocal, renderingGroupId, inputs: normalizeInputs(normalized.inputs) };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// 把调用方的 inputs(值 or provider)归一化为访问器:create() 里 inputs.aim() 总可调用(动态目标每帧取)。
|
|
178
|
+
function normalizeInputs(inputs?: VfxEffectInputs): VfxResolvedInputs | undefined {
|
|
179
|
+
if (!inputs) return undefined;
|
|
180
|
+
const asFn = <T>(v: T | (() => T) | undefined): (() => T) | undefined =>
|
|
181
|
+
v == null ? undefined : (typeof v === 'function' ? v as () => T : () => v);
|
|
182
|
+
const out: VfxResolvedInputs = {};
|
|
183
|
+
if (inputs.aim != null) out.aim = asFn(inputs.aim);
|
|
184
|
+
if (inputs.normal != null) out.normal = asFn(inputs.normal);
|
|
185
|
+
if (inputs.endpoints) out.endpoints = inputs.endpoints.map((e) => asFn(e)!).filter(Boolean);
|
|
186
|
+
if (inputs.target) out.target = inputs.target;
|
|
187
|
+
if (inputs.scalars) {
|
|
188
|
+
out.scalars = {};
|
|
189
|
+
for (const [k, v] of Object.entries(inputs.scalars)) {
|
|
190
|
+
const fn = asFn(v);
|
|
191
|
+
if (fn) out.scalars[k] = fn;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// G2:特效声明 requiredInputs 但调用方未提供对应 inputs → 警告(漏接静默降级很难查)。optionalInputs 不在此列。
|
|
198
|
+
function warnMissingRequiredInputs(effectId: string, required: VfxInputKey[] | undefined, inputs: VfxResolvedInputs | undefined): void {
|
|
199
|
+
if (!required || required.length === 0) return;
|
|
200
|
+
const missing = required.filter((k) => {
|
|
201
|
+
const v = inputs ? (inputs as Record<string, unknown>)[k] : undefined;
|
|
202
|
+
return v == null || (k === 'endpoints' && Array.isArray(v) && v.length < 2);
|
|
203
|
+
});
|
|
204
|
+
if (missing.length > 0) {
|
|
205
|
+
console.warn(`[EffectPackageService] effect "${effectId}" 声明 requiredInputs [${missing.join(', ')}] 但未提供 → 可能静默降级。`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
interface ResolvedSpawnTransform {
|
|
210
|
+
/** 按 offsetIsLocal 解释的位置:local(相对 parent)或世界坐标。 */
|
|
211
|
+
position: Vector3;
|
|
212
|
+
/** 完整欧拉角旋转(弧度)。 */
|
|
213
|
+
rotation: Vector3;
|
|
214
|
+
/** 有效 Y 轴旋转(弧度),透传给 create() 上下文(向后兼容)。 */
|
|
215
|
+
rotationY: number;
|
|
216
|
+
/** 三轴缩放。 */
|
|
217
|
+
scale: Vector3;
|
|
218
|
+
parent: TransformNode | null;
|
|
219
|
+
offsetIsLocal: boolean;
|
|
220
|
+
renderingGroupId?: number;
|
|
221
|
+
inputs?: VfxResolvedInputs;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// 把解析后的 spawn transform 落到 root 节点上。
|
|
225
|
+
// - 没给 parent:position/rotation/scale 即世界坐标(无 parent 时 local == world),与现状一致。
|
|
226
|
+
// - 给了 parent + offsetIsLocal(默认):写到 root 的 local transform(随 parent 跟随)。
|
|
227
|
+
// - 给了 parent + offsetIsLocal=false:position 按绝对世界坐标落位;rotation/scale 仍按 local 应用。
|
|
228
|
+
function applySpawnToRoot(root: TransformNode, spawn: ResolvedSpawnTransform): void {
|
|
229
|
+
if (spawn.parent) root.parent = spawn.parent;
|
|
230
|
+
root.rotation.copyFrom(spawn.rotation);
|
|
231
|
+
root.scaling.copyFrom(spawn.scale);
|
|
232
|
+
if (spawn.parent && !spawn.offsetIsLocal) {
|
|
233
|
+
// 需要先有 parent 的世界矩阵,才能把世界坐标反解成 local。
|
|
234
|
+
root.computeWorldMatrix(true);
|
|
235
|
+
root.setAbsolutePosition(spawn.position);
|
|
236
|
+
} else {
|
|
237
|
+
root.position.copyFrom(spawn.position);
|
|
157
238
|
}
|
|
239
|
+
root.computeWorldMatrix(true);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function resolveRotation(rotation: VfxSpawnTransform['rotation'], rotationY: unknown): Vector3 {
|
|
243
|
+
if (rotation) {
|
|
244
|
+
return new Vector3(
|
|
245
|
+
readFiniteNumber(rotation.x, 0),
|
|
246
|
+
readFiniteNumber(rotation.y, 0),
|
|
247
|
+
readFiniteNumber(rotation.z, 0),
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
return new Vector3(0, readFiniteNumber(rotationY, 0), 0);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function resolveScale(scale: VfxSpawnTransform['scale']): Vector3 {
|
|
254
|
+
if (scale == null) return new Vector3(1, 1, 1);
|
|
255
|
+
if (typeof scale === 'number') {
|
|
256
|
+
const s = readFiniteNumber(scale, 1);
|
|
257
|
+
return new Vector3(s, s, s);
|
|
258
|
+
}
|
|
259
|
+
return new Vector3(
|
|
260
|
+
readFiniteNumber(scale.x, 1),
|
|
261
|
+
readFiniteNumber(scale.y, 1),
|
|
262
|
+
readFiniteNumber(scale.z, 1),
|
|
263
|
+
);
|
|
158
264
|
}
|
|
159
265
|
|
|
160
266
|
function normalizeSpawnTransform(spawnTransform?: VfxSpawnTransform | Vector3): VfxSpawnTransform {
|
|
@@ -13,14 +13,17 @@ const DEFAULT_PARAMS: CoinBurstParams = { count: 6 };
|
|
|
13
13
|
export const coinBurstEffectPackage: VfxEffectPackage<CoinBurstParams> = {
|
|
14
14
|
id: 'coin-burst',
|
|
15
15
|
nameZh: '金币爆出',
|
|
16
|
+
placement: 'world',
|
|
16
17
|
debugAnchor: 'playerRight',
|
|
17
18
|
defaultParams: DEFAULT_PARAMS,
|
|
18
19
|
debugParams: [
|
|
19
20
|
{ key: 'count', label: '金币数量', kind: 'number', min: 0, max: 20, step: 1 },
|
|
20
21
|
],
|
|
21
22
|
|
|
22
|
-
create({ scene, root, position, params }) {
|
|
23
|
+
create({ scene, root, position, params, scale, renderingGroupId }) {
|
|
23
24
|
root.position.copyFrom(position);
|
|
25
|
+
// 金币是世界坐标抛物(不挂 root),root.scaling 作用不到 → 自行用 ctx.scale 缩放尺寸/抛撒/重力(G3/M6)。
|
|
26
|
+
const s = scale ? scale.x : 1;
|
|
24
27
|
const pool = new FlyerPool(scene);
|
|
25
28
|
const coinMat = new StandardMaterial('vfx_coin_mat', scene);
|
|
26
29
|
coinMat.diffuseColor = new Color3(1, 0.78, 0.1);
|
|
@@ -28,8 +31,10 @@ export const coinBurstEffectPackage: VfxEffectPackage<CoinBurstParams> = {
|
|
|
28
31
|
coinMat.specularColor = new Color3(1, 0.9, 0.4);
|
|
29
32
|
for (let i = 0; i < Math.round(params.count); i++) {
|
|
30
33
|
const c = CreateCylinder('vfx_coin', { diameter: 0.8, height: 0.12, tessellation: 18 }, scene);
|
|
34
|
+
c.scaling.setAll(s);
|
|
35
|
+
c.renderingGroupId = renderingGroupId ?? 0; // M7:补全分组(全库约定)
|
|
31
36
|
c.rotation.x = Math.random() * Math.PI; c.material = coinMat;
|
|
32
|
-
pool.spawn(c, position, new Vector3((Math.random() - 0.5) * 6, Math.random() * 6 + 4, (Math.random() - 0.5) * 6), 9, 1.9);
|
|
37
|
+
pool.spawn(c, position, new Vector3((Math.random() - 0.5) * 6 * s, (Math.random() * 6 + 4) * s, (Math.random() - 0.5) * 6 * s), 9 * s, 1.9);
|
|
33
38
|
}
|
|
34
39
|
let disposed = false;
|
|
35
40
|
const disposeTimer = window.setTimeout(() => disposeAll(), 2300);
|
|
@@ -53,6 +53,7 @@ function excludeFromGlow(scene: Scene, mesh: AbstractMesh): void {
|
|
|
53
53
|
export const cometTrailFlameEffectPackage: VfxEffectPackage<CometTrailFlameParams> = {
|
|
54
54
|
id: 'comet-trail-flame',
|
|
55
55
|
nameZh: '拖尾火焰',
|
|
56
|
+
placement: 'socket',
|
|
56
57
|
debugAnchor: 'player',
|
|
57
58
|
defaultParams: DEFAULT_PARAMS,
|
|
58
59
|
debugParams: [
|
|
@@ -63,7 +64,7 @@ export const cometTrailFlameEffectPackage: VfxEffectPackage<CometTrailFlameParam
|
|
|
63
64
|
{ key: 'tint', label: '火焰色', kind: 'color' },
|
|
64
65
|
],
|
|
65
66
|
|
|
66
|
-
create({ scene, root, position, rotationY, params }) {
|
|
67
|
+
create({ scene, root, position, rotationY, params, renderingGroupId }) {
|
|
67
68
|
root.position.copyFrom(position);
|
|
68
69
|
root.rotation.y = rotationY;
|
|
69
70
|
|
|
@@ -81,7 +82,7 @@ export const cometTrailFlameEffectPackage: VfxEffectPackage<CometTrailFlameParam
|
|
|
81
82
|
trailRoot.scaling.z = params.length;
|
|
82
83
|
const a = createTrailPlaneGeo('vfx_trail_a', params.width, 'x', scene);
|
|
83
84
|
const b = createTrailPlaneGeo('vfx_trail_b', params.width, 'y', scene);
|
|
84
|
-
for (const m of [a, b]) { m.material = mat; m.isPickable = false; m.parent = trailRoot; excludeFromGlow(scene, m); }
|
|
85
|
+
for (const m of [a, b]) { m.material = mat; m.isPickable = false; m.parent = trailRoot; m.renderingGroupId = renderingGroupId ?? 0; excludeFromGlow(scene, m); }
|
|
85
86
|
|
|
86
87
|
let t = 0;
|
|
87
88
|
const observer = scene.onBeforeRenderObservable.add(() => {
|
|
@@ -49,6 +49,7 @@ function loadRockTemplates(scene: Scene): Promise<Mesh[]> {
|
|
|
49
49
|
export const debrisSmokeEffectPackage: VfxEffectPackage<DebrisSmokeParams> = {
|
|
50
50
|
id: 'debris-smoke',
|
|
51
51
|
nameZh: '碎石烟雾',
|
|
52
|
+
placement: 'world',
|
|
52
53
|
debugAnchor: 'playerRight',
|
|
53
54
|
defaultParams: DEFAULT_PARAMS,
|
|
54
55
|
debugParams: [
|
|
@@ -18,6 +18,7 @@ const DEFAULT_PARAMS: ElectricArcParams = { radius: 1.2, rate: 48, duration: 0.5
|
|
|
18
18
|
export const electricArcEffectPackage: VfxEffectPackage<ElectricArcParams> = {
|
|
19
19
|
id: 'electric-arc',
|
|
20
20
|
nameZh: '受击电弧',
|
|
21
|
+
placement: 'world',
|
|
21
22
|
debugAnchor: 'playerRight',
|
|
22
23
|
defaultParams: DEFAULT_PARAMS,
|
|
23
24
|
debugParams: [
|
|
@@ -37,6 +37,7 @@ interface Flash { mesh: Mesh; mat: StandardMaterial; from: number; to: number; l
|
|
|
37
37
|
export const explosionEffectPackage: VfxEffectPackage<ExplosionParams> = {
|
|
38
38
|
id: 'explosion',
|
|
39
39
|
nameZh: '爆炸',
|
|
40
|
+
placement: 'world',
|
|
40
41
|
debugAnchor: 'playerRight',
|
|
41
42
|
defaultParams: DEFAULT_PARAMS,
|
|
42
43
|
debugParams: [
|
|
@@ -94,7 +95,7 @@ export const explosionEffectPackage: VfxEffectPackage<ExplosionParams> = {
|
|
|
94
95
|
const f = flashes[i]; f.life += dt; const k = Math.min(1, f.life / f.maxLife);
|
|
95
96
|
f.mesh.scaling.setAll(f.from + (f.to - f.from) * Math.sqrt(k));
|
|
96
97
|
f.mat.alpha = 1 - k;
|
|
97
|
-
if (k >= 1) { f.mesh.dispose(); f.mat.dispose(); flashes.splice(i, 1); }
|
|
98
|
+
if (k >= 1) { f.mesh.dispose(); f.mat.dispose(false, true); flashes.splice(i, 1); }
|
|
98
99
|
}
|
|
99
100
|
});
|
|
100
101
|
|
|
@@ -104,7 +105,7 @@ export const explosionEffectPackage: VfxEffectPackage<ExplosionParams> = {
|
|
|
104
105
|
if (disposed) return; disposed = true;
|
|
105
106
|
window.clearTimeout(disposeTimer);
|
|
106
107
|
scene.onBeforeRenderObservable.remove(observer as Observer<Scene>);
|
|
107
|
-
for (const f of flashes) { f.mesh.dispose(); f.mat.dispose(); }
|
|
108
|
+
for (const f of flashes) { f.mesh.dispose(); f.mat.dispose(false, true); }
|
|
108
109
|
flashes.length = 0;
|
|
109
110
|
sparksPs.dispose(); embersPs.dispose();
|
|
110
111
|
root.dispose();
|
|
@@ -28,9 +28,18 @@ function makeFlareTexture(scene: Scene, inner = '255,255,255', outer = '90,160,2
|
|
|
28
28
|
return dt;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// 贴图内容恒定:按 scene 缓存共享,免每发命中重建+GPU 上传(M2)。
|
|
32
|
+
const flareByScene = new WeakMap<Scene, DynamicTexture>();
|
|
33
|
+
function getFlareTexture(scene: Scene): DynamicTexture {
|
|
34
|
+
let dt = flareByScene.get(scene);
|
|
35
|
+
if (!dt || (dt as { isDisposed?: boolean }).isDisposed) { dt = makeFlareTexture(scene); flareByScene.set(scene, dt); }
|
|
36
|
+
return dt;
|
|
37
|
+
}
|
|
38
|
+
|
|
31
39
|
export const hitFlashEffectPackage: VfxEffectPackage<HitFlashParams> = {
|
|
32
40
|
id: 'hit-flash',
|
|
33
41
|
nameZh: '命中蓝白爆闪',
|
|
42
|
+
placement: 'world',
|
|
34
43
|
debugAnchor: 'playerRight',
|
|
35
44
|
defaultParams: DEFAULT_PARAMS,
|
|
36
45
|
debugParams: [
|
|
@@ -41,9 +50,8 @@ export const hitFlashEffectPackage: VfxEffectPackage<HitFlashParams> = {
|
|
|
41
50
|
|
|
42
51
|
create({ scene, root, position, params }) {
|
|
43
52
|
root.position.copyFrom(position);
|
|
44
|
-
const flareTex = makeFlareTexture(scene);
|
|
45
53
|
const ps = new ParticleSystem('vfx_hit_ps', 400, scene);
|
|
46
|
-
ps.particleTexture =
|
|
54
|
+
ps.particleTexture = getFlareTexture(scene);
|
|
47
55
|
ps.emitter = position.clone();
|
|
48
56
|
ps.minEmitBox = new Vector3(-0.1, -0.1, -0.1); ps.maxEmitBox = new Vector3(0.1, 0.1, 0.1);
|
|
49
57
|
ps.direction1 = new Vector3(-1, -1, -1); ps.direction2 = new Vector3(1, 1, 1);
|
|
@@ -62,7 +70,7 @@ export const hitFlashEffectPackage: VfxEffectPackage<HitFlashParams> = {
|
|
|
62
70
|
function disposeAll(): void {
|
|
63
71
|
if (disposed) return; disposed = true;
|
|
64
72
|
window.clearTimeout(disposeTimer);
|
|
65
|
-
ps.dispose();
|
|
73
|
+
ps.dispose(false); root.dispose(); // dispose(false):共享缓存纹理不随实例销毁
|
|
66
74
|
}
|
|
67
75
|
return { root, dispose() { disposeAll(); } };
|
|
68
76
|
},
|
|
@@ -7,6 +7,8 @@ import { CreatePlane } from '@babylonjs/core/Meshes/Builders/planeBuilder';
|
|
|
7
7
|
import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial';
|
|
8
8
|
import { Texture } from '@babylonjs/core/Materials/Textures/texture';
|
|
9
9
|
import { Color3 } from '@babylonjs/core/Maths/math.color';
|
|
10
|
+
import { Vector3 } from '@babylonjs/core/Maths/math.vector';
|
|
11
|
+
import { Space } from '@babylonjs/core/Maths/math.axis';
|
|
10
12
|
import { Constants } from '@babylonjs/core/Engines/constants';
|
|
11
13
|
import type { Scene } from '@babylonjs/core/scene';
|
|
12
14
|
import type { VfxEffectPackage, VfxParamValues } from '../types';
|
|
@@ -43,7 +45,9 @@ function createBeamPlaneGeo(name: string, width: number, axis: 'x' | 'y', scene:
|
|
|
43
45
|
export function createLaserBeamEffectPackage(id: string, nameZh: string, core: LaserCoreDefaults): VfxEffectPackage<LaserBeamParams> {
|
|
44
46
|
const defaultParams = { ...LIFETIME_DEFAULTS, ...core } as LaserBeamParams;
|
|
45
47
|
return {
|
|
46
|
-
|
|
48
|
+
// 定向武器 = socket 起点(parent 到炮口)+ aim 终点(optionalInputs)。geometrySpace local(束几何挂 root)。
|
|
49
|
+
// 'link' 留给真·多端点特效(本特效只有单 aim 终点,非 link)。见 #9-A。
|
|
50
|
+
id, nameZh, defaultParams, debugAnchor: 'playerRight', placement: 'socket', geometrySpace: 'local', optionalInputs: ['aim'],
|
|
47
51
|
debugParams: [
|
|
48
52
|
...LIFETIME_DEBUG_PARAMS,
|
|
49
53
|
{ key: 'width', label: '束宽', kind: 'scale', min: 0.05, max: 2, step: 0.01 },
|
|
@@ -51,7 +55,7 @@ export function createLaserBeamEffectPackage(id: string, nameZh: string, core: L
|
|
|
51
55
|
{ key: 'laserSpeed', label: '纹理流速', kind: 'number', min: 0, max: 20, step: 0.5 },
|
|
52
56
|
{ key: 'targetDistance', label: '目标距离', kind: 'number', min: 2, max: 40, step: 0.5 },
|
|
53
57
|
],
|
|
54
|
-
create({ scene, root, position, rotationY, params }) {
|
|
58
|
+
create({ scene, root, position, rotationY, params, inputs }) {
|
|
55
59
|
root.position.copyFrom(position);
|
|
56
60
|
root.rotation.y = rotationY;
|
|
57
61
|
const { width, intensity } = params;
|
|
@@ -100,7 +104,14 @@ export function createLaserBeamEffectPackage(id: string, nameZh: string, core: L
|
|
|
100
104
|
const observer = scene.onBeforeRenderObservable.add(() => {
|
|
101
105
|
const dt = Math.min(scene.getEngine().getDeltaTime() / 1000, 0.05);
|
|
102
106
|
time += dt;
|
|
103
|
-
|
|
107
|
+
// 有 inputs.aim:朝目标(束长=到目标距离);无 aim:回退正前方 targetDistance(预览)。
|
|
108
|
+
let dist = params.targetDistance;
|
|
109
|
+
if (inputs?.aim) {
|
|
110
|
+
const tgt = inputs.aim();
|
|
111
|
+
dist = Vector3.Distance(root.getAbsolutePosition(), tgt);
|
|
112
|
+
// Space.WORLD:root 挂在挂点上(有父节点),lookAt 默认按 LOCAL 解释目标点,必须显式 WORLD。
|
|
113
|
+
root.lookAt(tgt, 0, 0, 0, Space.WORLD);
|
|
114
|
+
}
|
|
104
115
|
beamRoot.scaling.z = dist;
|
|
105
116
|
const tiles = Math.max(1, dist / segLen);
|
|
106
117
|
(arcMat.emissiveTexture as Texture).uScale = tiles; (arcMat.opacityTexture as Texture).uScale = tiles;
|
|
@@ -21,7 +21,7 @@ interface MonsterHitParams extends VfxParamValues {
|
|
|
21
21
|
const DEFAULT_PARAMS: MonsterHitParams = {
|
|
22
22
|
flashCount: 16, flashSize: 1, flashLength: 1, flashBright: 2, flashColor: { r: 1, g: 0.92, b: 0.5 },
|
|
23
23
|
bloodCount: 20, bloodSize: 0.3, bloodGravity: 6, bloodColor: { r: 1, g: 0, b: 0 },
|
|
24
|
-
height:
|
|
24
|
+
height: 0,
|
|
25
25
|
};
|
|
26
26
|
|
|
27
27
|
// 程序化贴图(按场景缓存,随场景存活,不在 effect dispose 里销毁)
|
|
@@ -45,6 +45,7 @@ function c4(c: VfxColorValue, m = 1, a = 1): Color4 { return new Color4(c.r * m,
|
|
|
45
45
|
export const monsterHitEffectPackage: VfxEffectPackage<MonsterHitParams> = {
|
|
46
46
|
id: 'monster-hit',
|
|
47
47
|
nameZh: '怪物受击',
|
|
48
|
+
placement: 'world',
|
|
48
49
|
debugAnchor: 'playerRight',
|
|
49
50
|
defaultParams: DEFAULT_PARAMS,
|
|
50
51
|
debugParams: [
|