import { _decorator, Component, Node, sp, Tween } from 'cc'; import { SpriteLoad } from 'db://assets/mx/components/SpriteLoad'; import { ensureSpineSkeletonDataRuntime } from 'db://assets/mx/module/lang/spineRuntimePatch'; import { GBundle, GPath } from '../../game/ConfigRes'; import { BattleSpinePlayback } from '../presentation/BattleSpinePlayback'; import { applyGlyphSpineSkin, glyphSpineHasSkin, GLYPH_ACTIVE_SKIN_BUCKETS, GLYPH_SKIN_CAMP_ALLY, GLYPH_SKIN_CAMP_ENEMY, GLYPH_SKIN_IDLE, resolveActiveGlyphSkin, } from './GlyphSpineSkin'; const { ccclass } = _decorator; const SKIN_RETRY_MAX = 30; /** 组合英雄预制体上的品质框节点名 */ const QUALITY_FRAME = '品质框'; /** 幻魂镜节点名(与 BattleGameConfuseHeroFxPresenter 预制体名一致) */ const CONFUSE_MIRROR_CAST_NAME = '幻魂镜'; const CONFUSE_MIRROR_LOWER_NAME = '幻魂镜下层'; const CONFUSE_MIRROR_UPPER_NAME = '幻魂镜上层'; @ccclass('HeroSkin') export class HeroSkin extends Component { /** 英雄 Spine */ anim: sp.Skeleton = null; /** 英雄数据 */ Data: ITableHero = null; private _displayNode: Node | null = null; private _pendingSkinPreferred = ''; private _pendingSkinExtras: string[] = []; private _skinRetryCount = 0; private _attackCallback: (() => void) | null = null; private _qualityFramePath = ''; /** 是否显示 battleHero 预制体「品质框」节点(弹窗等可关;回池前恢复) */ private _qualityFrameVisible = true; private _level = 1; /** 敌方阵营皮肤:组合字用「皮肤字2_*」;基础字用 Hero.res+「2」 */ private _enemyCamp = false; /** 表现根(heroTemplate;本项目不再挂 prefab/hero 体) */ get displayNode(): Node | null { return this._displayNode?.isValid ? this._displayNode : null; } /** 字形 Spine 是否真正在画(active + enabled + 祖先链未关) */ isGlyphBodyVisible(): boolean { const anim = this.anim; if (!anim?.isValid || !anim.enabled) return false; let n: Node | null = anim.node; const root = this._displayNode ?? this.node; while (n?.isValid) { if (!n.active) return false; if (n === root) return true; n = n.parent; } return false; } /** * 幻魂镜下层:挂到表现根,层级在「品质框」之上。 * @param fx 幻魂镜下层节点 */ attachConfuseMirrorLowerFx(fx: Node): boolean { const root = this._displayNode; if (!root?.isValid || !fx?.isValid) return false; const frame = root.getChildByName(QUALITY_FRAME); fx.parent = root; fx.setPosition(0, 0, 0); fx.layer = root.layer; fx.active = true; if (frame?.isValid) { fx.setSiblingIndex(frame.getSiblingIndex() + 1); } else { console.log('[HeroSkin] 幻魂镜下层缺少品质框', this.Data?.id); fx.setSiblingIndex(0); } return true; } /** * 幻魂镜上层:挂到表现根最上层。 * @param fx 幻魂镜上层节点 */ attachConfuseMirrorUpperFx(fx: Node): boolean { const root = this._displayNode; if (!root?.isValid || !fx?.isValid) return false; fx.parent = root; fx.setPosition(0, 0, 0); fx.layer = root.layer; fx.active = true; fx.setSiblingIndex(root.children.length - 1); return true; } /** 剥离展示根上的幻魂镜施法/循环层(含跟丢的鬼手节点) */ detachConfuseMirrorFx(): void { const root = this._displayNode; if (root?.isValid) this._detachConfuseMirrorFxFromDisplay(root); } protected onDestroy(): void { this.releaseDisplay(); } /** 绑定英雄数据(不加载 prefab/hero) */ setData(data: ITableHero): void { this.Data = data; this._applyHeroDisplay(); } /** 回收展示节点(HeroActor 回池时调用) */ releaseDisplay(): void { this._releaseDisplay(); this.anim = null; } /** * 设置阵营皮肤标记(敌方加后缀「2」)。 * @param enemyCamp 是否敌方阵营 */ setEnemyCamp(enemyCamp: boolean): void { this._enemyCamp = !!enemyCamp; } /** 设置默认皮肤 */ setDefaultSkin(): void { this._level = 1; this._enemyCamp = false; this._qualityFrameVisible = true; this._applySkin(GLYPH_SKIN_IDLE, []); } /** * 设置品质框显隐。 * @param visible 是否显示 */ setQualityFrameVisible(visible: boolean): void { this._qualityFrameVisible = visible; this._applyQualityFrame(); } /** * 设置皮肤(等级 / 阵营)。 * 组合字:`皮肤字{camp}_{档}`,camp=1 己方 / 2 敌方(首位不是品质)。 * 基础字:走 Hero.res / res+「2」,与等级无关。 * @param level 合成等级 * @param _quality 兼容旧调用,忽略 * @param enemyCamp 可选:是否敌方阵营 */ setSkinByLevelAndQuality(level: number, enemyCamp?: boolean): void { this._level = Math.max(1, Math.floor(level)); if (enemyCamp !== undefined) this._enemyCamp = !!enemyCamp; const camp = this._enemyCamp ? GLYPH_SKIN_CAMP_ENEMY : GLYPH_SKIN_CAMP_ALLY; const preferred = resolveActiveGlyphSkin(this._level, camp); const extras = GLYPH_ACTIVE_SKIN_BUCKETS .map((b) => `皮肤字${camp}_${b}`) .filter((name) => name !== preferred); this._applySkin(preferred, extras); } /** 播放待机动画 */ playIdleAnim(timescale: number = 1): void { this._ensureGlyphAnimVisible(); BattleSpinePlayback.play(this.anim, '待机', { loop: true, timeScale: timescale, optional: true }); } /** * 播放释放动画(默认读 Skill.animation,缺省「攻击」)。 * @param animation Spine 动画名 */ playAttackAnim(timescale: number = 1, callback: (() => void) | null = null, animation: string = '攻击'): void { const anim = this.anim; if (!anim?.isValid) { callback?.(); return; } this._ensureGlyphAnimVisible(); const name = String(animation ?? '').trim() || '攻击'; BattleSpinePlayback.clearPendingComplete(anim); BattleSpinePlayback.play(anim, name, { loop: false, timeScale: timescale, onComplete: callback ?? undefined }); } private _resolveResName(): string { return String(this.Data?.res || this.Data?.name || '').trim(); } private _applyHeroDisplay(): void { this._displayNode = this.node?.isValid ? this.node : null; this._bindAnim(); this._ensureGlyphAnimVisible(); this._ensureShadowVisible(); this._applyQualityFrame(); this.playIdleAnim(); } /** 池化出池不会恢复子节点 active / Skeleton.enabled:必须整链打开,否则只剩品质框/阴影 */ private _ensureGlyphAnimVisible(): void { const anim = this.anim; if (!anim?.isValid) return; anim.enabled = true; anim.paused = false; let n: Node | null = anim.node; const root = this._displayNode ?? this.node; while (n?.isValid) { n.active = true; if (n === root) break; n = n.parent; } } /** 池化/换皮后恢复阴影子节点默认显隐 */ ensureShadowDefaultVisible(): void { this._ensureShadowVisible(); } /** 池化出池不会恢复子节点 active:阴影 ebg / ebg-001 默认都显示。 * 哪吒冲刺藏 ebg-001 由 NezhaChargePresenter 负责,挂载时先恢复默认。 */ private _ensureShadowVisible(): void { const shadow = this._displayNode?.getChildByName('阴影'); if (!shadow?.isValid) return; for (const child of shadow.children) { if (child?.isValid) child.active = true; } } /** 按 Hero.quality 加载 battleGame/texture/英雄品质_{n} */ private _applyQualityFrame(): void { const root = this._displayNode; const data = this.Data; if (!root?.isValid || !data) return; const frame = root.getChildByName(QUALITY_FRAME); if (!frame?.isValid) return; if (!this._qualityFrameVisible) { frame.active = false; return; } const quality = Math.floor(Number(data.quality) || 0); if (!(quality > 0)) { console.log('[HeroSkin] Hero.quality 无效', data.id, data.quality); frame.active = false; return; } frame.active = true; const path = GPath.BattleHeroQuality(quality); this._qualityFramePath = path; const sl = frame.getComponent(SpriteLoad) ?? frame.addComponent(SpriteLoad); sl.setSprite(path, GBundle.BattleHero, this._onQualityFrameLoaded); } /** 品质框图加载结果 */ private readonly _onQualityFrameLoaded = (ok: boolean): void => { if (ok) return; console.log('[HeroSkin] 英雄品质框加载失败', this._qualityFramePath, this.Data?.id); }; /** 优先挂「动画」节点的 Spine */ private _bindAnim(): void { const root = this._displayNode; if (!root?.isValid) { this.anim = null; return; } const animNode = root.getChildByName('动画'); this.anim = animNode?.getComponent(sp.Skeleton) ?? root.getComponent(sp.Skeleton) ?? root.getComponentInChildren(sp.Skeleton); } private _releaseDisplay(): void { const node = this._displayNode; this._displayNode = null; this._clearAttackListener(); this.anim = null; if (!node?.isValid) return; this._qualityFrameVisible = true; this._restoreQualityFrameForPool(node); this._detachConfuseMirrorFxFromDisplay(node); Tween.stopAllByTarget(node); node.angle = 0; } /** 回池前剥离幻魂镜节点,避免鬼手随 display 进池或残留在场景 */ private _detachConfuseMirrorFxFromDisplay(root: Node): void { this._releaseConfuseMirrorChild(root, CONFUSE_MIRROR_CAST_NAME); this._releaseConfuseMirrorChild(root, CONFUSE_MIRROR_LOWER_NAME); this._releaseConfuseMirrorChild(root, CONFUSE_MIRROR_UPPER_NAME); } private _releaseConfuseMirrorChild(root: Node, childName: string): void { const child = root.getChildByName(childName); if (!child?.isValid) return; const skel = child.getComponent(sp.Skeleton) ?? child.getComponentInChildren(sp.Skeleton); if (skel?.isValid) BattleSpinePlayback.clearPendingComplete(skel); Tween.stopAllByTarget(child); child.removeFromParent(); if (child['resKey']) gg.res.putNode(child); else child.destroy(); } /** 回池前恢复品质框默认显隐,避免弹窗隐藏状态污染战斗复用 */ private _restoreQualityFrameForPool(root: Node): void { const frame = root.getChildByName(QUALITY_FRAME); if (!frame?.isValid) return; const quality = Math.floor(Number(this.Data?.quality) || 0); frame.active = quality > 0; } private _applySkin(preferred: string, extras: readonly string[]): void { this._pendingSkinPreferred = preferred; this._pendingSkinExtras = [...extras]; this._tryApplyPendingSkin(); } private _tryApplyPendingSkin(): void { const preferred = this._pendingSkinPreferred; if (!preferred) return; const anim = this.anim; if (!anim?.isValid) return; const sd = anim.skeletonData; if (!sd?.isValid) { this._scheduleSkinRetry(); return; } if (!this._skeletonTexturesReady(sd)) { this._scheduleSkinRetry(); return; } ensureSpineSkeletonDataRuntime(sd); // 组合字等级皮(皮肤字*):骨架有才套,禁止强行 setSkin 破坏「棍/枪」等基础皮 const hasGlyphSkin = glyphSpineHasSkin(anim, preferred) || this._pendingSkinExtras.some((n) => glyphSpineHasSkin(anim, n)); if (hasGlyphSkin && applyGlyphSpineSkin(anim, preferred, this._pendingSkinExtras)) { this._skinRetryCount = 0; this._ensureIdleIfNoTrack(anim); return; } // 基础英雄:Hero.res 为己方皮(如「棍」);敌方阵营才用「棍2」(与等级无关) if (this._applyHeroResSkin(anim)) { this._skinRetryCount = 0; this._ensureIdleIfNoTrack(anim); return; } if (hasGlyphSkin && this._scheduleSkinRetry()) return; // 保留预制体默认皮(棍 prefab defaultSkin=棍),只保证有待机轨 this._skinRetryCount = 0; this._ensureIdleIfNoTrack(anim); } /** * 将 Hero.res 阵营皮套到目标 Skeleton(字形或攻效)。 * 敌方优先 res+「2」。对象池复用攻效时须每次出手调用。 * @param anim 目标 Spine */ applyHeroResSkinTo(anim: sp.Skeleton | null): boolean { if (!anim?.isValid) return false; const ok = this._applyHeroResSkin(anim); if (this._enemyCamp) { const resName = this._resolveResName(); const want = resName ? `${resName}2` : ''; if (want && !glyphSpineHasSkin(anim, want)) { console.warn('[HeroSkin] 缺少敌方皮肤', want); } } return ok; } /** 用 Hero.res 作为 Spine skin;敌方阵营优先 res+「2」 */ private _applyHeroResSkin(anim: sp.Skeleton): boolean { const resName = this._resolveResName(); if (!resName) return false; const names = this._enemyCamp ? [`${resName}2`, resName] : [resName]; for (let i = 0; i < names.length; i++) { const name = names[i]; if (!glyphSpineHasSkin(anim, name)) continue; try { anim.setSkin(name); anim.setSlotsToSetupPose(); return true; } catch { // 下一候选 } } return false; } private _scheduleSkinRetry(): boolean { if (this._skinRetryCount >= SKIN_RETRY_MAX) return false; this._skinRetryCount++; this.scheduleOnce(() => this._tryApplyPendingSkin(), 0.05); return true; } private _skeletonTexturesReady(sd: sp.SkeletonData): boolean { const textures = (sd as sp.SkeletonData & { textures?: unknown[] }).textures; return Array.isArray(textures) && textures.length > 0; } private _ensureIdleIfNoTrack(anim: sp.Skeleton): void { const cur = anim.getCurrent?.(0); const rawName = cur?.animation?.name; if (typeof rawName === 'string' && rawName.length > 0 && rawName !== '') return; BattleSpinePlayback.play(anim, '待机', { loop: true, optional: true }); } private _playNamed(name: string, loop: boolean, timescale: number): boolean { const anim = this.anim; if (!anim?.isValid) return false; return BattleSpinePlayback.play(anim, name, { loop, timeScale: timescale }); } private _clearAttackListener(): void { if (this.anim?.isValid) BattleSpinePlayback.clearPendingComplete(this.anim); } }