You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
AdLike2048/assets/script/battle/view/HitEffectResolver.ts

315 lines
11 KiB

3 weeks ago
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';
3 days ago
import { ensureSpineSkeletonDataRuntime } from 'db://assets/mx/module/lang/spineRuntimePatch';
3 weeks ago
import { GBundle } from '../../game/ConfigRes';
import { BombSpine } from '../../ui/BattleGame/WeaponSkill/BombSpine';
import { DeviceProfile } from './DeviceProfile';
3 days ago
/** 五行受击:battleGame/prefab/受击特效,动画「受击皮肤」,第三段为皮肤名 */
export const DEFAULT_HIT_EFFECT = '受击皮肤|受击特效';
3 weeks ago
/** 怪物死亡: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 {
3 days ago
/** Spine 动画名 */
3 weeks ago
spineAnim: string;
3 days ago
/** 预制体名 */
3 weeks ago
prefabName: string;
3 days ago
/** 是否朝向攻击来源 */
3 weeks ago
faceSource: boolean;
3 days ago
/** 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}`;
3 weeks ago
}
function parseHitEffectCfg(hitEffectCfg: string): ParsedHitEffect | null {
const parts = hitEffectCfg.split('|').map(s => s.trim());
if (parts.length < 2) return null;
3 days ago
const head = parts[0];
const prefabName = parts[1];
3 weeks ago
if (!head || !prefabName) return null;
if (head === '刀痕') {
3 days ago
return { spineAnim: 'animation', prefabName, faceSource: true, spineSkin: '' };
3 weeks ago
}
3 days ago
const spineSkin = parts.length >= 3 ? parts[2] : '';
return { spineAnim: head, prefabName, faceSource: false, spineSkin };
3 weeks ago
}
/**
* 受击/死亡 Spine 特效。
3 days ago
* 配置格式:"动画名|prefab名" 或 "动画名|prefab名|皮肤名";「刀痕」固定播 animation 并朝向来源。
3 weeks ago
*/
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;
}
3 days ago
const { spineAnim, prefabName, faceSource, spineSkin } = parsed;
3 weeks ago
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);
}
3 days ago
this._applyHitSpineSkin(spineNode, spineSkin);
3 weeks ago
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);
});
}
3 days ago
/**
* 受击特效套五行皮(池化复用前每发必设)。
* @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));
}
}
3 weeks ago
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);
});
}
}