@cc-component/cc-fly 0.0.0-stage → 1.0.0
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/.cc-ex-component.md +12 -0
- package/assets/fly/Interface.ts +32 -0
- package/assets/fly/Interface.ts.meta +9 -0
- package/assets/fly/base/BuffUI.ts +68 -0
- package/assets/fly/base/BuffUI.ts.meta +9 -0
- package/assets/fly/base/FlyArrowLauncher.ts +166 -0
- package/assets/fly/base/FlyArrowLauncher.ts.meta +9 -0
- package/assets/fly/base/FlyBaseBullet.ts +9 -0
- package/assets/fly/base/FlyBaseBullet.ts.meta +9 -0
- package/assets/fly/base/FlyBaseHit.ts +10 -0
- package/assets/fly/base/FlyBaseHit.ts.meta +9 -0
- package/assets/fly/base/FlyBaseHp.ts +96 -0
- package/assets/fly/base/FlyBaseHp.ts.meta +9 -0
- package/assets/fly/base/FlyBasePlayer.ts +359 -0
- package/assets/fly/base/FlyBasePlayer.ts.meta +9 -0
- package/assets/fly/base/FlyDamageLabel.ts +198 -0
- package/assets/fly/base/FlyDamageLabel.ts.meta +9 -0
- package/assets/fly/base/FlyDamageLabelManager.ts +111 -0
- package/assets/fly/base/FlyDamageLabelManager.ts.meta +9 -0
- package/assets/fly/base/FlyModule.ts +145 -0
- package/assets/fly/base/FlyModule.ts.meta +9 -0
- package/assets/fly/base/FlyNodePoolUtil.ts +56 -0
- package/assets/fly/base/FlyNodePoolUtil.ts.meta +9 -0
- package/assets/fly/base/FlyPlayerBuffSystem.ts +673 -0
- package/assets/fly/base/FlyPlayerBuffSystem.ts.meta +9 -0
- package/assets/fly/base/FlyPooledParticleEffect.ts +58 -0
- package/assets/fly/base/FlyPooledParticleEffect.ts.meta +9 -0
- package/assets/fly/base/FlySkiiManager.ts +19 -0
- package/assets/fly/base/FlySkiiManager.ts.meta +9 -0
- package/assets/fly/base.meta +9 -0
- package/assets/fly.meta +9 -0
- package/assets/pathfinding.meta +9 -0
- package/assets.meta +9 -0
- package/index.ts +19 -0
- package/index.ts.meta +9 -0
- package/package.json +18 -5
- package/package.json.meta +11 -0
- package/README.md +0 -3
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { _decorator, Component, Node } from 'cc';
|
|
2
|
+
import { FlyArrowLauncher } from './FlyArrowLauncher';
|
|
3
|
+
import { FlyPlayerBuffSystem, PlayerBuffAttackResult, PlayerBuffOptions, PlayerBuffType, PlayerDamageType } from './FlyPlayerBuffSystem';
|
|
4
|
+
import { Enum } from 'cc';
|
|
5
|
+
import { Vec3 } from 'cc';
|
|
6
|
+
import { FlyBaseHp } from './FlyBaseHp';
|
|
7
|
+
import { FlySkiiManager } from './FlySkiiManager';
|
|
8
|
+
import { UITransform } from 'cc';
|
|
9
|
+
const { ccclass, property } = _decorator;
|
|
10
|
+
export interface PlayerOptions {
|
|
11
|
+
attackDamageType?: PlayerDamageType;
|
|
12
|
+
baseAttackInterval?: number;
|
|
13
|
+
bulletEffectParent?: Node | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type PlayerAttackHitCallback<T extends FlyBasePlayer> =
|
|
17
|
+
(target: T, hitPosition: Vec3, buffType: PlayerBuffType | any) => void;
|
|
18
|
+
|
|
19
|
+
@ccclass('FlyBasePlayer')
|
|
20
|
+
export class FlyBasePlayer extends Component {
|
|
21
|
+
@property({ type: Enum(PlayerDamageType), displayName: '攻击类型' })
|
|
22
|
+
attackDamageType = PlayerDamageType.Physical;
|
|
23
|
+
@property({
|
|
24
|
+
type: UITransform,
|
|
25
|
+
displayName: '碰撞盒',
|
|
26
|
+
tooltip: '玩家的碰撞盒;',
|
|
27
|
+
})
|
|
28
|
+
boxNode: UITransform | null = null;
|
|
29
|
+
|
|
30
|
+
// 未受攻速 Buff 影响时,两次攻击之间的基础等待时间,单位为秒。
|
|
31
|
+
@property({ displayName: '基础攻击间隔(秒)', min: 0 })
|
|
32
|
+
baseAttackInterval = 0.5;
|
|
33
|
+
|
|
34
|
+
@property({
|
|
35
|
+
type: Node,
|
|
36
|
+
displayName: '子弹特效父节点',
|
|
37
|
+
tooltip: '子弹和命中特效的挂载父节点;',
|
|
38
|
+
})
|
|
39
|
+
bulletEffectParent: Node | null = null;
|
|
40
|
+
|
|
41
|
+
@property({
|
|
42
|
+
type: FlyArrowLauncher,
|
|
43
|
+
displayName: '子弹发射器',
|
|
44
|
+
tooltip: '技能发射器挂载节点;',
|
|
45
|
+
})
|
|
46
|
+
flyArrowLauncher: FlyArrowLauncher | null = null;
|
|
47
|
+
|
|
48
|
+
@property({
|
|
49
|
+
type: FlyPlayerBuffSystem,
|
|
50
|
+
displayName: 'buff管理器',
|
|
51
|
+
tooltip: 'buff 管理器挂载节点;',
|
|
52
|
+
})
|
|
53
|
+
buffSystem: FlyPlayerBuffSystem | null = null;
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@property({
|
|
57
|
+
type: FlyBaseHp,
|
|
58
|
+
displayName: '血量管理器',
|
|
59
|
+
tooltip: '基础血量挂载节点;',
|
|
60
|
+
})
|
|
61
|
+
flyBaseHp: FlyBaseHp | null = null;
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
public baseMaxHp: number | null = null;
|
|
65
|
+
|
|
66
|
+
onLoad(): void {
|
|
67
|
+
if (!this.boxNode) {
|
|
68
|
+
this.boxNode = this.node.getComponent(UITransform) ?? null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
}
|
|
72
|
+
start() {
|
|
73
|
+
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 通过代码配置 Player 参数,可在 onLoad 前或运行时调用。 */
|
|
77
|
+
public configure(options: PlayerOptions): void {
|
|
78
|
+
if (options.attackDamageType !== undefined) {
|
|
79
|
+
this.attackDamageType = options.attackDamageType;
|
|
80
|
+
}
|
|
81
|
+
// 无效间隔不更新;有效值限制为非负数。
|
|
82
|
+
if (options.baseAttackInterval !== undefined && Number.isFinite(options.baseAttackInterval)) {
|
|
83
|
+
this.baseAttackInterval = Math.max(0, options.baseAttackInterval);
|
|
84
|
+
}
|
|
85
|
+
if (options.bulletEffectParent !== undefined) {
|
|
86
|
+
this.bulletEffectParent = options.bulletEffectParent;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**碰撞盒子 */
|
|
92
|
+
getPlayerBox(): { width: number; height: number } {
|
|
93
|
+
return this.boxNode?.contentSize ?? { width: 0, height: 0 };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
/** 尝试向目标位置发射子弹;处于攻击冷却时不会发射。 */
|
|
99
|
+
public fire(end: Vec3, onHit?: (hitPosition: Vec3, arrow: Node) => void): void {
|
|
100
|
+
const start = this.node;
|
|
101
|
+
// 攻速 Buff 系统负责冷却校验;冷却中则不发射。
|
|
102
|
+
if (!start || !end || !this.buffSystem.tryUseAttack(this.baseAttackInterval)) return;
|
|
103
|
+
this.flyArrowLauncher.fire(start.worldPosition, end, (hitPosition, arrow) => {
|
|
104
|
+
onHit?.(hitPosition, arrow);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
//#region 发射子弹
|
|
109
|
+
/**
|
|
110
|
+
* 发射一次可命中多个目标的攻击。候选目标由场景层提供,以避免基础组件依赖特定阵营或节点结构。
|
|
111
|
+
*/
|
|
112
|
+
public fireAtTargets<T extends FlyBasePlayer>(
|
|
113
|
+
buffType: PlayerBuffType | any,
|
|
114
|
+
primaryTarget: T,
|
|
115
|
+
candidateTargets: readonly T[],
|
|
116
|
+
onHit?: PlayerAttackHitCallback<T>,
|
|
117
|
+
): void {
|
|
118
|
+
if (!primaryTarget || !primaryTarget.node.isValid || primaryTarget.isDead || !this.buffSystem.tryUseAttack(this.baseAttackInterval)) return;
|
|
119
|
+
|
|
120
|
+
//播放技能动画效果
|
|
121
|
+
FlySkiiManager.getInstance().EmitSkillAnim?.(buffType, this, primaryTarget, candidateTargets, (target) => {
|
|
122
|
+
if (!this.isValidAttackTarget(target)) return;
|
|
123
|
+
onHit?.(target as T, target.node.worldPosition, buffType);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 选择本次攻击目标:主目标始终优先,其他目标按与主目标的距离从近到远排列。
|
|
131
|
+
* 范围 Buff 先筛选候选目标;目标数量 Buff 再限制最终总数。
|
|
132
|
+
*/
|
|
133
|
+
public getAttackTargets<T extends FlyBasePlayer>(
|
|
134
|
+
primaryTarget: T,
|
|
135
|
+
candidateTargets: readonly T[],
|
|
136
|
+
): T[] {
|
|
137
|
+
if (!this.isValidAttackTarget(primaryTarget)) return [];
|
|
138
|
+
|
|
139
|
+
const { targetCount, areaAttackRadius } = this.buffSystem.getAttackTargetConfig();
|
|
140
|
+
const primaryPosition = primaryTarget.node.worldPosition;
|
|
141
|
+
const uniqueCandidates = new Set<T>();
|
|
142
|
+
const additionalTargets = candidateTargets.filter(candidate => {
|
|
143
|
+
if (candidate === primaryTarget || candidate.node === this.node || uniqueCandidates.has(candidate)) return false;
|
|
144
|
+
if (!this.isValidAttackTarget(candidate)) return false;
|
|
145
|
+
uniqueCandidates.add(candidate);
|
|
146
|
+
return areaAttackRadius === undefined
|
|
147
|
+
|| Vec3.distance(primaryPosition, candidate.node.worldPosition) <= areaAttackRadius;
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
additionalTargets.sort((left, right) =>
|
|
151
|
+
Vec3.distance(primaryPosition, left.node.worldPosition)
|
|
152
|
+
- Vec3.distance(primaryPosition, right.node.worldPosition),
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const targets = [primaryTarget, ...additionalTargets];
|
|
156
|
+
return targetCount === undefined ? targets : targets.slice(0, targetCount);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** 验证目标是否为有效的攻击目标 */
|
|
160
|
+
public isValidAttackTarget(target: FlyBasePlayer | null | undefined): target is FlyBasePlayer {
|
|
161
|
+
return !!target && target !== this && target?.node?.isValid && !target.isDead;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
/** 当前玩家是否已死亡;未配置血量组件时视为存活。 */
|
|
166
|
+
public get isDead(): boolean {
|
|
167
|
+
return this.flyBaseHp?.isDead ?? false;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** 玩家死亡后销毁其节点;存活时不执行操作。 */
|
|
171
|
+
public destroyIfDead(): void {
|
|
172
|
+
if (this.isDead && this.node.isValid) {
|
|
173
|
+
this.node.destroy();
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** 当前最大生命值;未配置血量组件时返回 0。 */
|
|
178
|
+
public get maxHp(): number {
|
|
179
|
+
return this.flyBaseHp?.maxHp ?? 0;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* 按伤害类型选择对应防御 Buff,计算并应用实际伤害。
|
|
184
|
+
* @returns 经过防御计算后的实际伤害值。
|
|
185
|
+
*/
|
|
186
|
+
public takeDamage(
|
|
187
|
+
amount: number,
|
|
188
|
+
damageType = PlayerDamageType.Physical,
|
|
189
|
+
attackerBuffs?: FlyPlayerBuffSystem,
|
|
190
|
+
): number {
|
|
191
|
+
const defenseType = damageType === PlayerDamageType.Magic
|
|
192
|
+
? PlayerBuffType.MagicDefense
|
|
193
|
+
: PlayerBuffType.PhysicalDefense;
|
|
194
|
+
const actualDamage = this.buffSystem.calculateIncomingDamage(amount, defenseType, attackerBuffs);
|
|
195
|
+
this.flyBaseHp?.takeDamage(actualDamage);
|
|
196
|
+
return actualDamage;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** 恢复生命值,可选择是否播放治疗动画。 */
|
|
200
|
+
public heal(amount: number, animate = true): void {
|
|
201
|
+
this.flyBaseHp?.heal(amount, animate);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* 重复添加时同类型 Buff 数值累加。DoT 配置会在命中时尝试施加给目标。
|
|
206
|
+
*/
|
|
207
|
+
public addBuff(options: PlayerBuffOptions): void {
|
|
208
|
+
const {
|
|
209
|
+
type,
|
|
210
|
+
value,
|
|
211
|
+
chance = 1,
|
|
212
|
+
duration,
|
|
213
|
+
damageType = PlayerDamageType.Magic,
|
|
214
|
+
onApplied,
|
|
215
|
+
} = options;
|
|
216
|
+
|
|
217
|
+
if (type === PlayerBuffType.CurrentHealth) {
|
|
218
|
+
this.buffSystem.applyCurrentHealthBuff(
|
|
219
|
+
value,
|
|
220
|
+
chance,
|
|
221
|
+
amount => this.applyHealthBuff(amount, true, onApplied),
|
|
222
|
+
duration,
|
|
223
|
+
);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (type === PlayerBuffType.DamageOverTime) {
|
|
228
|
+
this.buffSystem.addDamageOverTimeBuff(value, chance, duration, damageType);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
this.buffSystem.addBuff(type, value, chance, duration, () => this.onBuffExpired(type));
|
|
233
|
+
this.onBuffChanged(type);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* 替换指定类型的现有 Buff。DoT 配置只替换攻击触发配置。
|
|
238
|
+
*/
|
|
239
|
+
public resetBuff(options: PlayerBuffOptions): void {
|
|
240
|
+
const {
|
|
241
|
+
type,
|
|
242
|
+
value,
|
|
243
|
+
chance = 1,
|
|
244
|
+
duration,
|
|
245
|
+
damageType = PlayerDamageType.Magic,
|
|
246
|
+
onApplied,
|
|
247
|
+
} = options;
|
|
248
|
+
|
|
249
|
+
if (type === PlayerBuffType.CurrentHealth) {
|
|
250
|
+
this.buffSystem.applyCurrentHealthBuff(
|
|
251
|
+
value,
|
|
252
|
+
chance,
|
|
253
|
+
amount => this.applyHealthBuff(amount, false, onApplied),
|
|
254
|
+
duration,
|
|
255
|
+
true,
|
|
256
|
+
);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (type === PlayerBuffType.DamageOverTime) {
|
|
261
|
+
this.buffSystem.resetDamageOverTimeBuff(value, chance, duration, damageType);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
this.buffSystem.resetBuff(type, value, chance, duration, () => this.onBuffExpired(type));
|
|
266
|
+
this.onBuffChanged(type);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** 应用治疗类 Buff,并将实际恢复量传给回调。 */
|
|
270
|
+
public applyHealthBuff(
|
|
271
|
+
amount: number,
|
|
272
|
+
animate: boolean,
|
|
273
|
+
onApplied?: (healedAmount: number) => void,
|
|
274
|
+
): void {
|
|
275
|
+
const hp = this.flyBaseHp;
|
|
276
|
+
const previousHp = hp?.currentHp ?? 0;
|
|
277
|
+
this.heal(amount, animate);
|
|
278
|
+
const healedAmount = (hp?.currentHp ?? previousHp) - previousHp;
|
|
279
|
+
if (healedAmount > 0) onApplied?.(healedAmount);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** 将攻击者的 DoT 效果交由 BuffSystem 按命中概率施加给目标。 */
|
|
283
|
+
public applyDamageOverTimeOnHit(target: FlyBasePlayer, onDamage?: (damage: number) => void): void {
|
|
284
|
+
if (target.isDead) return;
|
|
285
|
+
|
|
286
|
+
this.buffSystem.applyDamageOverTimeOnHit(
|
|
287
|
+
target.buffSystem,
|
|
288
|
+
(damage, damageType) => () => {
|
|
289
|
+
const actualDamage = target.takeDamage(damage, damageType);
|
|
290
|
+
if (actualDamage > 0) onDamage?.(actualDamage);
|
|
291
|
+
target.destroyIfDead();
|
|
292
|
+
},
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Buff 变化时同步需要即时反映到血量组件的属性。 */
|
|
297
|
+
protected onBuffChanged(type: PlayerBuffType): void {
|
|
298
|
+
if (type === PlayerBuffType.MaxHealth) {
|
|
299
|
+
this.syncBuffedMaxHp();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Buff 到期时同步需要即时反映到血量组件的属性。 */
|
|
305
|
+
protected onBuffExpired(type: PlayerBuffType): void {
|
|
306
|
+
if (type === PlayerBuffType.MaxHealth) {
|
|
307
|
+
this.syncBuffedMaxHp();
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** 移除指定 Buff,并同步受该 Buff 影响的属性。 */
|
|
312
|
+
public removeBuff(type: PlayerBuffType): void {
|
|
313
|
+
this.buffSystem.removeBuff(type);
|
|
314
|
+
this.onBuffChanged(type);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** 清除所有 Buff;具体重置逻辑由 resetBuffs 执行。 */
|
|
318
|
+
public clearBuffs(): void {
|
|
319
|
+
this.resetBuffs();
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** 重置 Buff 系统,并重新同步相关属性。 */
|
|
323
|
+
public resetBuffs(): void {
|
|
324
|
+
this.buffSystem.resetBuffs();
|
|
325
|
+
this.onBuffChanged(PlayerBuffType.MaxHealth);
|
|
326
|
+
this.onBuffChanged(PlayerBuffType.MovementSpeed);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** 查询玩家是否拥有指定类型的 Buff。 */
|
|
330
|
+
public hasBuff(type: PlayerBuffType): boolean {
|
|
331
|
+
return this.buffSystem.hasBuff(type);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** 根据基础伤害和目标最大生命值计算最终攻击结果。 */
|
|
335
|
+
public calculateAttack(baseDamage: number, targetMaxHp = 0): PlayerBuffAttackResult {
|
|
336
|
+
return this.buffSystem.calculateAttack(baseDamage, targetMaxHp);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** 根据本次伤害计算生命偷取量。 */
|
|
340
|
+
public calculateLifesteal(damage: number): number {
|
|
341
|
+
return this.buffSystem.calculateLifesteal(damage);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** 保留当前生命比例,并按最大生命值 Buff 更新生命上限与当前生命值。 */
|
|
345
|
+
public syncBuffedMaxHp(): void {
|
|
346
|
+
const hp = this.flyBaseHp;
|
|
347
|
+
if (!hp) return;
|
|
348
|
+
this.baseMaxHp ??= hp.maxHp;
|
|
349
|
+
const currentHpRatio = hp.maxHp > 0
|
|
350
|
+
? Math.max(0, Math.min(hp.currentHp / hp.maxHp, 1))
|
|
351
|
+
: 1;
|
|
352
|
+
const effectiveMaxHp = this.baseMaxHp + this.buffSystem.getBuffValue(PlayerBuffType.MaxHealth);
|
|
353
|
+
hp.configure({
|
|
354
|
+
maxHp: effectiveMaxHp,
|
|
355
|
+
currentHp: effectiveMaxHp * currentHpRatio,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { _decorator, Component, Node, Label, BitmapFont, Font, UIOpacity, tween, Tween, Vec3, Color } from 'cc';
|
|
2
|
+
|
|
3
|
+
const { ccclass, property } = _decorator;
|
|
4
|
+
|
|
5
|
+
export enum FlyDamageLabelType {
|
|
6
|
+
/** 普通伤害飘字。 */
|
|
7
|
+
Damage,
|
|
8
|
+
/** 暴击伤害飘字。 */
|
|
9
|
+
Critical,
|
|
10
|
+
/** 治疗回复飘字。 */
|
|
11
|
+
Heal,
|
|
12
|
+
/** 中毒持续伤害飘字。 */
|
|
13
|
+
Poison,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
@ccclass('FlyDamageLabel')
|
|
17
|
+
export class FlyDamageLabel extends Component {
|
|
18
|
+
@property({ type: Node, displayName: '挂载父节点', tooltip: '飘字显示在这个父节点上,默认使用当前节点父节点' })
|
|
19
|
+
parentNode: Node | null = null;
|
|
20
|
+
|
|
21
|
+
@property({ type: Label, displayName: '标签组件', tooltip: '用于显示伤害值的标签组件' })
|
|
22
|
+
label: Label = null;
|
|
23
|
+
|
|
24
|
+
@property({ displayName: '上升高度', tooltip: '数字上升的距离' })
|
|
25
|
+
riseHeight = 60;
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
private isAnimating = false;
|
|
30
|
+
private originalOpacity: number | null = null;
|
|
31
|
+
|
|
32
|
+
@property({ displayName: '最高点停顿时长', tooltip: '飘字到达最高点后等待的秒数' })
|
|
33
|
+
peakPauseDuration = 0.1;
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@property({ displayName: '动画持续时间', tooltip: '飘字动画的总持续时间' })
|
|
37
|
+
animationDuration = 1;
|
|
38
|
+
|
|
39
|
+
@property({ displayName: '普通伤害颜色' })
|
|
40
|
+
damageColor = new Color(255, 255, 255, 255);
|
|
41
|
+
|
|
42
|
+
@property({ displayName: '暴击颜色' })
|
|
43
|
+
criticalColor = new Color(255, 96, 64, 255);
|
|
44
|
+
|
|
45
|
+
@property({ displayName: '治疗颜色' })
|
|
46
|
+
healColor = new Color(80, 255, 120, 255);
|
|
47
|
+
|
|
48
|
+
@property({ displayName: '中毒颜色' })
|
|
49
|
+
poisonColor = new Color(180, 80, 220, 255);
|
|
50
|
+
|
|
51
|
+
@property({ type: BitmapFont, displayName: '普通伤害字体' })
|
|
52
|
+
damageFont: BitmapFont | null = null;
|
|
53
|
+
|
|
54
|
+
@property({ type: BitmapFont, displayName: '暴击字体' })
|
|
55
|
+
criticalFont: BitmapFont | null = null;
|
|
56
|
+
|
|
57
|
+
@property({ type: BitmapFont, displayName: '治疗字体' })
|
|
58
|
+
healFont: BitmapFont | null = null;
|
|
59
|
+
|
|
60
|
+
private originalFont: Font | null = null;
|
|
61
|
+
private hasOriginalFont = false;
|
|
62
|
+
|
|
63
|
+
private get activeLabel(): Label {
|
|
64
|
+
if (this.label) return this.label;
|
|
65
|
+
this.label = this.node.getComponent(Label) ?? this.node.addComponent(Label);
|
|
66
|
+
return this.label;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
onLoad(): void {
|
|
70
|
+
;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
public show(
|
|
74
|
+
position: Vec3,
|
|
75
|
+
value: number,
|
|
76
|
+
parent: Node | null = this.parentNode ?? this.node.parent,
|
|
77
|
+
type: FlyDamageLabelType = FlyDamageLabelType.Damage,
|
|
78
|
+
): void {
|
|
79
|
+
if (!parent) return;
|
|
80
|
+
|
|
81
|
+
const label = this.activeLabel;
|
|
82
|
+
if (!this.hasOriginalFont) {
|
|
83
|
+
this.originalFont = label.font;
|
|
84
|
+
this.hasOriginalFont = true;
|
|
85
|
+
}
|
|
86
|
+
const localPos = new Vec3(
|
|
87
|
+
position.x - parent.worldPosition.x,
|
|
88
|
+
position.y - parent.worldPosition.y,
|
|
89
|
+
0,
|
|
90
|
+
);
|
|
91
|
+
this.reset();
|
|
92
|
+
const opacity = this.node.getComponent(UIOpacity) ?? this.node.addComponent(UIOpacity);
|
|
93
|
+
if (this.originalOpacity === null) {
|
|
94
|
+
this.originalOpacity = opacity.opacity;
|
|
95
|
+
}
|
|
96
|
+
this.isAnimating = true;
|
|
97
|
+
|
|
98
|
+
this.node.parent = parent;
|
|
99
|
+
this.node.setPosition(localPos);
|
|
100
|
+
this.setLabelStyle(label, value, type);
|
|
101
|
+
opacity.opacity = 0;
|
|
102
|
+
this.node.active = true;
|
|
103
|
+
|
|
104
|
+
this.playAnimation(localPos, opacity);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
protected setLabelStyle(label: Label, value: number, type: FlyDamageLabelType): void {
|
|
108
|
+
const absoluteValue = Math.abs(value);
|
|
109
|
+
switch (type) {
|
|
110
|
+
case FlyDamageLabelType.Critical:
|
|
111
|
+
label.string = `暴击 -${absoluteValue}`;
|
|
112
|
+
label.color = this.criticalColor.clone();
|
|
113
|
+
label.font = this.criticalFont ?? this.originalFont;
|
|
114
|
+
break;
|
|
115
|
+
case FlyDamageLabelType.Heal:
|
|
116
|
+
label.string = `+${absoluteValue}`;
|
|
117
|
+
label.color = this.healColor.clone();
|
|
118
|
+
label.font = this.healFont ?? this.originalFont;
|
|
119
|
+
break;
|
|
120
|
+
case FlyDamageLabelType.Poison:
|
|
121
|
+
label.string = `-${absoluteValue}`;
|
|
122
|
+
label.color = this.poisonColor.clone();
|
|
123
|
+
label.font = this.damageFont ?? this.originalFont;
|
|
124
|
+
break;
|
|
125
|
+
default:
|
|
126
|
+
label.string = `-${absoluteValue}`;
|
|
127
|
+
label.color = this.damageColor.clone();
|
|
128
|
+
label.font = this.damageFont ?? this.originalFont;
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
protected playAnimation(startPosition: Vec3, opacity: UIOpacity): void {
|
|
134
|
+
const totalDuration = Math.max(this.animationDuration, this.peakPauseDuration + 0.3);
|
|
135
|
+
const movementDuration = totalDuration - this.peakPauseDuration;
|
|
136
|
+
const fallDuration = movementDuration * 0.6;
|
|
137
|
+
const riseDuration = movementDuration - fallDuration;
|
|
138
|
+
const fadeInDuration = 0.1;
|
|
139
|
+
const fadeDuration = Math.min(0.5, totalDuration - fadeInDuration);
|
|
140
|
+
const peakPos = new Vec3(startPosition.x, startPosition.y + this.riseHeight, 0);
|
|
141
|
+
// 随机选择下落方向、横向偏移和下落距离。
|
|
142
|
+
const horizontalDirection = Math.random() < 0.5 ? -1 : 1;
|
|
143
|
+
const horizontalDrift = horizontalDirection * (50 + Math.random() * 28);
|
|
144
|
+
const fallDistance = Math.max(100, this.riseHeight * (0.6 + Math.random() * 0.3));
|
|
145
|
+
const fallPos = new Vec3(peakPos.x + horizontalDrift, peakPos.y - fallDistance, 0);
|
|
146
|
+
|
|
147
|
+
// 保持原始大小,平滑上升至最高点,停顿后再斜向下落。
|
|
148
|
+
tween(this.node)
|
|
149
|
+
.to(riseDuration, { position: peakPos }, { easing: 'sineInOut' })
|
|
150
|
+
.delay(this.peakPauseDuration)
|
|
151
|
+
.to(fallDuration, { position: fallPos }, {
|
|
152
|
+
easing: 'linear',
|
|
153
|
+
onUpdate: (_target, ratio) => {
|
|
154
|
+
const progress = Math.max(0, Math.min(ratio, 1));
|
|
155
|
+
this.node.setPosition(
|
|
156
|
+
peakPos.x + horizontalDrift * progress,
|
|
157
|
+
peakPos.y - fallDistance * progress * progress,
|
|
158
|
+
peakPos.z,
|
|
159
|
+
);
|
|
160
|
+
},
|
|
161
|
+
})
|
|
162
|
+
.call(() => this.reset())
|
|
163
|
+
.start();
|
|
164
|
+
|
|
165
|
+
// 位移期间保持显示,并在动画末段淡出。
|
|
166
|
+
tween(opacity)
|
|
167
|
+
.to(fadeInDuration, { opacity: this.originalOpacity })
|
|
168
|
+
.delay(totalDuration - fadeInDuration - fadeDuration)
|
|
169
|
+
.to(fadeDuration, { opacity: 0 })
|
|
170
|
+
.start();
|
|
171
|
+
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
public reset(): void {
|
|
175
|
+
if (!this.isAnimating && !this.node.parent && !this.node.active) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const label = this.activeLabel;
|
|
180
|
+
const opacity = this.node.getComponent(UIOpacity);
|
|
181
|
+
Tween.stopAllByTarget(this.node);
|
|
182
|
+
if (opacity) {
|
|
183
|
+
Tween.stopAllByTarget(opacity);
|
|
184
|
+
opacity.opacity = this.originalOpacity ?? 255;
|
|
185
|
+
}
|
|
186
|
+
if (this.node.parent) {
|
|
187
|
+
this.node.removeFromParent();
|
|
188
|
+
}
|
|
189
|
+
this.node.active = false;
|
|
190
|
+
label.string = '';
|
|
191
|
+
label.color = this.damageColor.clone();
|
|
192
|
+
if (this.hasOriginalFont) {
|
|
193
|
+
label.font = this.originalFont;
|
|
194
|
+
}
|
|
195
|
+
this.node.setScale(1, 1, 1);
|
|
196
|
+
this.isAnimating = false;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { _decorator, Component, Node, Vec3, NodePool, instantiate, Prefab, Enum } from 'cc';
|
|
2
|
+
import { FlyDamageLabel, FlyDamageLabelType } from './FlyDamageLabel';
|
|
3
|
+
|
|
4
|
+
const { ccclass, property } = _decorator;
|
|
5
|
+
|
|
6
|
+
export enum FlyDamageLabelTemplateType {
|
|
7
|
+
Node,
|
|
8
|
+
Prefab,
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
@ccclass('FlyDamageLabelManager')
|
|
12
|
+
export class FlyDamageLabelManager extends Component {
|
|
13
|
+
@property({ type: Enum(FlyDamageLabelTemplateType), displayName: '资源类型' })
|
|
14
|
+
templateType = FlyDamageLabelTemplateType.Node;
|
|
15
|
+
|
|
16
|
+
@property({ type: Node, displayName: '模板节点', tooltip: '当资源类型为 Node 时使用', visible(this: FlyDamageLabelManager) { return this.templateType === FlyDamageLabelTemplateType.Node; } })
|
|
17
|
+
template: Node | null = null;
|
|
18
|
+
|
|
19
|
+
@property({ type: Prefab, displayName: '模板 Prefab', tooltip: '当资源类型为 Prefab 时使用', visible(this: FlyDamageLabelManager) { return this.templateType === FlyDamageLabelTemplateType.Prefab; } })
|
|
20
|
+
prefabTemplate: Prefab | null = null;
|
|
21
|
+
|
|
22
|
+
@property({ displayName: '缓存数量', tooltip: '预创建的伤害文本节点数量' })
|
|
23
|
+
cacheCount = 8;
|
|
24
|
+
|
|
25
|
+
private battery: NodePool = new NodePool();
|
|
26
|
+
|
|
27
|
+
protected onLoad(): void {
|
|
28
|
+
this.ensureTemplate();
|
|
29
|
+
for (let i = 0; i < this.cacheCount; i++) {
|
|
30
|
+
const node = this.createNode();
|
|
31
|
+
node.active = false;
|
|
32
|
+
this.battery.put(node);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private ensureTemplate(): void {
|
|
37
|
+
if (this.templateType === FlyDamageLabelTemplateType.Node) {
|
|
38
|
+
if (this.template) return;
|
|
39
|
+
const templateNode = new Node('FlyDamageLabelTemplate');
|
|
40
|
+
templateNode.addComponent(FlyDamageLabel);
|
|
41
|
+
this.template = templateNode;
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (this.prefabTemplate) return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private createNode(): Node {
|
|
49
|
+
if (this.templateType === FlyDamageLabelTemplateType.Node) {
|
|
50
|
+
if (!this.template) {
|
|
51
|
+
this.ensureTemplate();
|
|
52
|
+
}
|
|
53
|
+
const node = instantiate(this.template!);
|
|
54
|
+
node.name = 'FlyDamageLabelItem';
|
|
55
|
+
node.active = false;
|
|
56
|
+
return node;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!this.prefabTemplate) {
|
|
60
|
+
this.ensureTemplate();
|
|
61
|
+
}
|
|
62
|
+
const node = instantiate(this.prefabTemplate!);
|
|
63
|
+
node.name = 'FlyDamageLabelItem';
|
|
64
|
+
node.active = false;
|
|
65
|
+
return node;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
public show(
|
|
69
|
+
position: Vec3,
|
|
70
|
+
value: number,
|
|
71
|
+
type: FlyDamageLabelType = FlyDamageLabelType.Damage,
|
|
72
|
+
parent: Node | null = this.node.parent ?? this.node,
|
|
73
|
+
|
|
74
|
+
): void {
|
|
75
|
+
if (!parent) return;
|
|
76
|
+
|
|
77
|
+
let labelNode = this.battery.get() as Node | null;
|
|
78
|
+
if (!labelNode) {
|
|
79
|
+
labelNode = this.createNode();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const label = labelNode.getComponent(FlyDamageLabel);
|
|
83
|
+
if (!label) {
|
|
84
|
+
labelNode.addComponent(FlyDamageLabel);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const activeLabel = labelNode.getComponent(FlyDamageLabel)!;
|
|
88
|
+
activeLabel.reset();
|
|
89
|
+
labelNode.parent = parent;
|
|
90
|
+
labelNode.active = true;
|
|
91
|
+
activeLabel.show(position, value, parent, type);
|
|
92
|
+
|
|
93
|
+
this.scheduleOnce(() => {
|
|
94
|
+
this.recycle(labelNode);
|
|
95
|
+
}, activeLabel.animationDuration + 0.05);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
public recycle(node: Node): void {
|
|
99
|
+
if (!node || !node.isValid) return;
|
|
100
|
+
|
|
101
|
+
const label = node.getComponent(FlyDamageLabel);
|
|
102
|
+
label?.reset();
|
|
103
|
+
node.removeFromParent();
|
|
104
|
+
node.active = false;
|
|
105
|
+
if (this.battery.size() < this.cacheCount) {
|
|
106
|
+
this.battery.put(node);
|
|
107
|
+
} else {
|
|
108
|
+
node.destroy();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|