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 { GBundle } from '../../game/ConfigRes'; import { BombSpine } from '../../ui/BattleGame/WeaponSkill/BombSpine'; import { DeviceProfile } from './DeviceProfile'; /** 通用受击:battleGame/prefab/受击特效 + Spine 动画 受击2 */ export const DEFAULT_HIT_EFFECT = '受击2|受击特效|animation'; /** 怪物死亡: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 { spineAnim: string; prefabName: string; faceSource: boolean; } function parseHitEffectCfg(hitEffectCfg: string): ParsedHitEffect | null { const parts = hitEffectCfg.split('|').map(s => s.trim()); if (parts.length < 2) return null; const [head, prefabName] = parts; if (!head || !prefabName) return null; if (head === '刀痕') { return { spineAnim: 'animation', prefabName, faceSource: true }; } return { spineAnim: head, prefabName, faceSource: false }; } /** * 受击/死亡 Spine 特效。 * 配置格式:"Spine动画名|prefab名";类型「刀痕」时固定播 animation 并朝向 faceSource 方向。 */ export class HitEffectResolver { private _missing = createMembershipSet(); private _warned = createMembershipSet(); /** 同屏受击特效数(死亡 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 } = 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); } 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); }); } 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>): 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); }); } }