|
|
|
|
import { assetManager, instantiate, type Node, Prefab, Sorting2D, sp, type Vec3 } from 'cc';
|
|
|
|
|
import { createMembershipSet, membershipAdd, membershipFromArray, membershipDedupeArray, type MembershipSet, type ReadonlyMembershipSet } from '../../../mx/tools/MembershipSet';
|
|
|
|
|
import { ensureSpineSkeletonDataRuntime } from 'db://assets/mx/module/lang/spineRuntimePatch';
|
|
|
|
|
import { GBundle } from '../../game/ConfigRes';
|
|
|
|
|
import { BombSpine } from '../../ui/BattleGame/WeaponSkill/BombSpine';
|
|
|
|
|
import { DeviceProfile } from './DeviceProfile';
|
|
|
|
|
|
|
|
|
|
/** 五行受击:battleGame/prefab/受击特效,动画「受击皮肤」,第三段为皮肤名 */
|
|
|
|
|
export const DEFAULT_HIT_EFFECT = '受击皮肤|受击特效';
|
|
|
|
|
/** 怪物死亡:battleGame prefab/死亡 + Spine animation */
|
|
|
|
|
export const MONSTER_DEATH_EFFECT = 'animation|死亡';
|
|
|
|
|
/** 刀类普攻刀痕:prefab/受击刀痕,播放时朝向攻击来源 */
|
|
|
|
|
export const SLASH_MARK_HIT_EFFECT = '刀痕|受击刀痕';
|
|
|
|
|
/** 刀痕 Spine 播放倍速(2 = 动画时长减半) */
|
|
|
|
|
const SLASH_MARK_ANIM_SPEED = 2;
|
|
|
|
|
|
|
|
|
|
interface ParsedHitEffect {
|
|
|
|
|
/** Spine 动画名 */
|
|
|
|
|
spineAnim: string;
|
|
|
|
|
/** 预制体名 */
|
|
|
|
|
prefabName: string;
|
|
|
|
|
/** 是否朝向攻击来源 */
|
|
|
|
|
faceSource: boolean;
|
|
|
|
|
/** Spine 皮肤名(金木水火土);空则不改皮 */
|
|
|
|
|
spineSkin: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Hero.herotype → 受击皮肤名。
|
|
|
|
|
* @param herotype 1金 2木 3水 4火 5土
|
|
|
|
|
*/
|
|
|
|
|
export function hitSkinForHerotype(herotype: number): string {
|
|
|
|
|
switch (Math.floor(Number(herotype) || 0)) {
|
|
|
|
|
case 1: return '金';
|
|
|
|
|
case 2: return '木';
|
|
|
|
|
case 3: return '水';
|
|
|
|
|
case 4: return '火';
|
|
|
|
|
case 5: return '土';
|
|
|
|
|
default: return '';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 按五行拼受击配置:`受击皮肤|受击特效|金`。
|
|
|
|
|
* @param herotype Hero.herotype
|
|
|
|
|
*/
|
|
|
|
|
export function hitEffectCfgForHerotype(herotype: number): string {
|
|
|
|
|
const skin = hitSkinForHerotype(herotype);
|
|
|
|
|
if (!skin) {
|
|
|
|
|
console.warn('[HitEffectResolver] herotype 无对应受击皮肤', herotype);
|
|
|
|
|
return DEFAULT_HIT_EFFECT;
|
|
|
|
|
}
|
|
|
|
|
return `${DEFAULT_HIT_EFFECT}|${skin}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseHitEffectCfg(hitEffectCfg: string): ParsedHitEffect | null {
|
|
|
|
|
const parts = hitEffectCfg.split('|').map(s => s.trim());
|
|
|
|
|
if (parts.length < 2) return null;
|
|
|
|
|
const head = parts[0];
|
|
|
|
|
const prefabName = parts[1];
|
|
|
|
|
if (!head || !prefabName) return null;
|
|
|
|
|
if (head === '刀痕') {
|
|
|
|
|
return { spineAnim: 'animation', prefabName, faceSource: true, spineSkin: '' };
|
|
|
|
|
}
|
|
|
|
|
const spineSkin = parts.length >= 3 ? parts[2] : '';
|
|
|
|
|
return { spineAnim: head, prefabName, faceSource: false, spineSkin };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 受击/死亡 Spine 特效。
|
|
|
|
|
* 配置格式:"动画名|prefab名" 或 "动画名|prefab名|皮肤名";「刀痕」固定播 animation 并朝向来源。
|
|
|
|
|
*/
|
|
|
|
|
export class HitEffectResolver {
|
|
|
|
|
private _missing = createMembershipSet<string>();
|
|
|
|
|
private _warned = createMembershipSet<string>();
|
|
|
|
|
/** 同屏受击特效数(死亡 force 不占) */
|
|
|
|
|
private static _activeBurst = 0;
|
|
|
|
|
/** 退局自增,作废未完成的回扣 */
|
|
|
|
|
private static _burstGen = 0;
|
|
|
|
|
|
|
|
|
|
/** 对局结束:清爆发计数 */
|
|
|
|
|
static resetBurst(): void {
|
|
|
|
|
HitEffectResolver._burstGen++;
|
|
|
|
|
HitEffectResolver._activeBurst = 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 占用一个受击特效名额。
|
|
|
|
|
* @returns 世代号;满额返回 -1
|
|
|
|
|
*/
|
|
|
|
|
private static _beginHitBurst(): number {
|
|
|
|
|
if (HitEffectResolver._activeBurst >= DeviceProfile.hitEffectBurstMax) return -1;
|
|
|
|
|
HitEffectResolver._activeBurst++;
|
|
|
|
|
return HitEffectResolver._burstGen;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 释放受击特效名额。
|
|
|
|
|
* @param gen 占用时的世代
|
|
|
|
|
*/
|
|
|
|
|
private static _endHitBurst(gen: number): void {
|
|
|
|
|
if (gen < 0 || gen !== HitEffectResolver._burstGen) return;
|
|
|
|
|
HitEffectResolver._activeBurst = Math.max(0, HitEffectResolver._activeBurst - 1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 在指定父节点上、世界坐标处播放通用受击特效 */
|
|
|
|
|
playDefaultAt(parent: Node, worldPos: Vec3): void {
|
|
|
|
|
this.play(DEFAULT_HIT_EFFECT, parent, worldPos);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 怪物死亡:battleGame/prefab/死亡,不受 low 档关闭受击特效影响 */
|
|
|
|
|
playMonsterDeathAt(parent: Node, worldPos: Vec3, onComplete?: () => void): void {
|
|
|
|
|
this._playAt(
|
|
|
|
|
MONSTER_DEATH_EFFECT,
|
|
|
|
|
parent,
|
|
|
|
|
worldPos,
|
|
|
|
|
GBundle.BattleGame,
|
|
|
|
|
'prefab/',
|
|
|
|
|
true,
|
|
|
|
|
0,
|
|
|
|
|
0,
|
|
|
|
|
onComplete,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 在指定父节点上、世界坐标处播放受击 Spine;faceSource 为攻击来源相对受击点的方向(可选) */
|
|
|
|
|
play(
|
|
|
|
|
hitEffectCfg: string | undefined,
|
|
|
|
|
parent: Node,
|
|
|
|
|
worldPos: Vec3,
|
|
|
|
|
faceSourceX = 0,
|
|
|
|
|
faceSourceY = 0,
|
|
|
|
|
): void {
|
|
|
|
|
if (!DeviceProfile.enableHitEffect) return;
|
|
|
|
|
this._playAt(
|
|
|
|
|
hitEffectCfg,
|
|
|
|
|
parent,
|
|
|
|
|
worldPos,
|
|
|
|
|
GBundle.BattleGame,
|
|
|
|
|
'prefab/',
|
|
|
|
|
false,
|
|
|
|
|
faceSourceX,
|
|
|
|
|
faceSourceY,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private _playAt(
|
|
|
|
|
hitEffectCfg: string | undefined,
|
|
|
|
|
parent: Node,
|
|
|
|
|
worldPos: Vec3,
|
|
|
|
|
bundle: string,
|
|
|
|
|
dir: string,
|
|
|
|
|
force: boolean,
|
|
|
|
|
faceSourceX = 0,
|
|
|
|
|
faceSourceY = 0,
|
|
|
|
|
onComplete?: () => void,
|
|
|
|
|
): void {
|
|
|
|
|
if (!force && !DeviceProfile.enableHitEffect) {
|
|
|
|
|
onComplete?.();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!hitEffectCfg || !parent?.isValid) {
|
|
|
|
|
onComplete?.();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const parsed = parseHitEffectCfg(hitEffectCfg);
|
|
|
|
|
if (!parsed) {
|
|
|
|
|
onComplete?.();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { spineAnim, prefabName, faceSource, spineSkin } = parsed;
|
|
|
|
|
const key = `${bundle}/${dir}${prefabName}`;
|
|
|
|
|
if (this._missing.has(key)) {
|
|
|
|
|
onComplete?.();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const burstGen = force ? -1 : HitEffectResolver._beginHitBurst();
|
|
|
|
|
if (!force && burstGen < 0) {
|
|
|
|
|
onComplete?.();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this._acquireNode(bundle, dir, prefabName, key, (spineNode) => {
|
|
|
|
|
if (!spineNode?.isValid || !parent.isValid) {
|
|
|
|
|
HitEffectResolver._endHitBurst(burstGen);
|
|
|
|
|
onComplete?.();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
for (const sk of spineNode.getComponentsInChildren(sp.Skeleton)) {
|
|
|
|
|
if (sk?.isValid) sk.enabled = true;
|
|
|
|
|
}
|
|
|
|
|
spineNode.active = true;
|
|
|
|
|
spineNode.layer = parent.layer;
|
|
|
|
|
spineNode.setParent(parent);
|
|
|
|
|
spineNode.worldPosition = worldPos;
|
|
|
|
|
spineNode.setSiblingIndex(parent.children.length - 1);
|
|
|
|
|
if (faceSource) {
|
|
|
|
|
const len = Math.hypot(faceSourceX, faceSourceY);
|
|
|
|
|
spineNode.angle = len > 1e-3
|
|
|
|
|
? Math.atan2(faceSourceY, faceSourceX) * 180 / Math.PI
|
|
|
|
|
: 0;
|
|
|
|
|
} else {
|
|
|
|
|
spineNode.angle = 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const sort of spineNode.getComponentsInChildren(Sorting2D)) {
|
|
|
|
|
sort.sortingOrder = force ? 50 : Math.max(sort.sortingOrder, 10);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this._applyHitSpineSkin(spineNode, spineSkin);
|
|
|
|
|
const bomb = spineNode.getComponent(BombSpine) ?? spineNode.addComponent(BombSpine);
|
|
|
|
|
const animSpeed = faceSource ? SLASH_MARK_ANIM_SPEED : 1;
|
|
|
|
|
bomb.playSpine(spineAnim, () => {
|
|
|
|
|
bomb.destoryNode();
|
|
|
|
|
HitEffectResolver._endHitBurst(burstGen);
|
|
|
|
|
onComplete?.();
|
|
|
|
|
}, animSpeed, false);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 受击特效套五行皮(池化复用前每发必设)。
|
|
|
|
|
* @param spineNode 特效根
|
|
|
|
|
* @param skinName 金木水火土;空则跳过
|
|
|
|
|
*/
|
|
|
|
|
private _applyHitSpineSkin(spineNode: Node, skinName: string): void {
|
|
|
|
|
if (!skinName) return;
|
|
|
|
|
const sk = spineNode.getComponent(sp.Skeleton)
|
|
|
|
|
?? spineNode.getComponentInChildren(sp.Skeleton);
|
|
|
|
|
if (!sk?.isValid) {
|
|
|
|
|
console.warn('[HitEffectResolver] 受击特效缺 Skeleton', skinName);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (sk.skeletonData) {
|
|
|
|
|
ensureSpineSkeletonDataRuntime(sk.skeletonData);
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
sk.setSkin(skinName);
|
|
|
|
|
sk.setSlotsToSetupPose?.();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.warn('[HitEffectResolver] setSkin 失败', skinName, String(err));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private _acquireNode(
|
|
|
|
|
bundleName: string,
|
|
|
|
|
dir: string,
|
|
|
|
|
prefabName: string,
|
|
|
|
|
key: string,
|
|
|
|
|
onReady: (node: Node | null) => void,
|
|
|
|
|
): void {
|
|
|
|
|
let spineNode: Node | null = null;
|
|
|
|
|
try {
|
|
|
|
|
spineNode = gg.res.getNode(dir, prefabName, bundleName);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.log('[HitEffectResolver] getNode failed', key, String(e));
|
|
|
|
|
membershipAdd(this._missing, key);
|
|
|
|
|
onReady(null);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (spineNode?.isValid) {
|
|
|
|
|
onReady(spineNode);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const path = dir + prefabName;
|
|
|
|
|
const resKey = bundleName + path;
|
|
|
|
|
const applyPrefab = (prefab: Prefab): void => {
|
|
|
|
|
const node = instantiate(prefab);
|
|
|
|
|
node['resKey'] = resKey;
|
|
|
|
|
onReady(node);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const loadFromBundle = (bundle: NonNullable<ReturnType<typeof assetManager.getBundle>>): void => {
|
|
|
|
|
const hit = bundle.get(path, Prefab) as Prefab | null;
|
|
|
|
|
if (hit) {
|
|
|
|
|
applyPrefab(hit);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
bundle.load(path, Prefab, (err, prefab) => {
|
|
|
|
|
if (err || !prefab) {
|
|
|
|
|
// 真机偶发 load 失败(内存/IO)不可永久拉黑,否则本局后续死亡特效全灭
|
|
|
|
|
if (!this._warned.has(key)) {
|
|
|
|
|
membershipAdd(this._warned, key);
|
|
|
|
|
console.log('[HitEffectResolver] prefab load failed', key, String(err));
|
|
|
|
|
}
|
|
|
|
|
onReady(null);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
applyPrefab(prefab);
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const bundle = assetManager.getBundle(bundleName);
|
|
|
|
|
if (bundle) {
|
|
|
|
|
loadFromBundle(bundle);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
assetManager.loadBundle(bundleName, (err, loaded) => {
|
|
|
|
|
if (err || !loaded) {
|
|
|
|
|
if (!this._warned.has(key)) {
|
|
|
|
|
membershipAdd(this._warned, key);
|
|
|
|
|
console.log('[HitEffectResolver] bundle missing', bundleName, String(err));
|
|
|
|
|
}
|
|
|
|
|
onReady(null);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
loadFromBundle(loaded);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|