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.
513 lines
19 KiB
513 lines
19 KiB
|
3 weeks ago
|
import { _decorator, Component, instantiate, Node, Prefab, 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 { isComboHero } from '../config/BattleHeroTable';
|
||
|
|
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 _displayResName = '';
|
||
|
|
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;
|
||
|
|
|
||
|
|
/** 英雄展示预制体根节点 */
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 幻魂镜下层:挂到 battleHero 合成预制体根,层级在「品质框」之上。
|
||
|
|
* @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;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 幻魂镜上层:挂到 battleHero 合成预制体根最上层。
|
||
|
|
* @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();
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 设置英雄数据并加载对应预制体 */
|
||
|
|
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, []);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 设置 battleHero 子预制体品质框显隐。
|
||
|
|
* @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 {
|
||
|
|
const resName = this._resolveResName();
|
||
|
|
if (!resName) {
|
||
|
|
console.error('[HeroSkin] Hero.res 为空', this.Data?.id);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (this._displayResName === resName && this._displayNode?.isValid) {
|
||
|
|
this._bindAnim();
|
||
|
|
this._ensureGlyphAnimVisible();
|
||
|
|
this._ensureShadowVisible();
|
||
|
|
this._applyQualityFrame();
|
||
|
|
this.playIdleAnim();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
this._releaseDisplay();
|
||
|
|
const node = gg.res.getNode(GPath.HeroPrefab(), resName, GBundle.BattleHero);
|
||
|
|
if (!node?.isValid) {
|
||
|
|
console.log('[HeroSkin] 英雄预制体未加载,异步拉取', resName, 'heroId=', this.Data?.id);
|
||
|
|
this._loadHeroAsync(resName, 0);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
this._mountDisplay(node, resName);
|
||
|
|
}
|
||
|
|
|
||
|
|
private _mountDisplay(node: Node, resName: string): void {
|
||
|
|
if (!this.node?.isValid) {
|
||
|
|
gg.res.putNode(node);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
node.parent = this.node;
|
||
|
|
Tween.stopAllByTarget(node);
|
||
|
|
node.setPosition(0, 0, 0);
|
||
|
|
node.angle = 0;
|
||
|
|
node.active = true;
|
||
|
|
this._displayNode = node;
|
||
|
|
this._displayResName = resName;
|
||
|
|
this._bindAnim();
|
||
|
|
this._ensureGlyphAnimVisible();
|
||
|
|
this._ensureShadowVisible();
|
||
|
|
this._applyQualityFrame();
|
||
|
|
this.setSkinByLevelAndQuality(this._level);
|
||
|
|
this.playIdleAnim();
|
||
|
|
} catch (e) {
|
||
|
|
console.error('[HeroSkin] 挂载英雄预制体异常', resName, this.Data?.id, String(e));
|
||
|
|
this._releaseDisplay();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 池化出池不会恢复子节点 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) {
|
||
|
|
if (isComboHero(data)) {
|
||
|
|
console.log('[HeroSkin] 组合英雄缺少品质框', data.id);
|
||
|
|
}
|
||
|
|
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);
|
||
|
|
};
|
||
|
|
|
||
|
|
/** 优先挂 hero 预制体下「动画」节点的 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);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 异步拉取 battleHero 预制体。
|
||
|
|
* @param resName Hero.res
|
||
|
|
* @param attempt 已失败次数
|
||
|
|
*/
|
||
|
|
private _loadHeroAsync(resName: string, attempt: number): void {
|
||
|
|
gg.res.loadAsset(GBundle.BattleHero, GPath.HeroPrefab(), resName, Prefab, (prefab) => {
|
||
|
|
if (!this.isValid || this._resolveResName() !== resName) return;
|
||
|
|
if (!prefab?.isValid) {
|
||
|
|
if (attempt < 1) {
|
||
|
|
console.warn('[HeroSkin] 英雄预制体加载失败,重试', resName, 'heroId=', this.Data?.id);
|
||
|
|
this.scheduleOnce(() => {
|
||
|
|
if (!this.isValid || this._resolveResName() !== resName) return;
|
||
|
|
this._loadHeroAsync(resName, attempt + 1);
|
||
|
|
}, 0.4);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
console.error('[HeroSkin] 英雄预制体加载失败', resName, 'heroId=', this.Data?.id);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const node = gg.res.getNode(GPath.HeroPrefab(), resName, GBundle.BattleHero);
|
||
|
|
if (node?.isValid) {
|
||
|
|
this._mountDisplay(node, resName);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
const inst = instantiate(prefab as Prefab);
|
||
|
|
this._mountDisplay(inst, resName);
|
||
|
|
} catch (e) {
|
||
|
|
console.error('[HeroSkin] instantiate 英雄预制体异常', resName, this.Data?.id, String(e));
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
private _releaseDisplay(): void {
|
||
|
|
const node = this._displayNode;
|
||
|
|
this._displayNode = null;
|
||
|
|
this._displayResName = '';
|
||
|
|
this._clearAttackListener();
|
||
|
|
if (node?.isValid) {
|
||
|
|
this._qualityFrameVisible = true;
|
||
|
|
this._restoreQualityFrameForPool(node);
|
||
|
|
this._detachConfuseMirrorFxFromDisplay(node);
|
||
|
|
Tween.stopAllByTarget(node);
|
||
|
|
node.angle = 0;
|
||
|
|
gg.res.putNode(node);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 回池前剥离幻魂镜节点,避免鬼手随 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) {
|
||
|
|
this._scheduleSkinRetry();
|
||
|
|
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 !== '<None>') 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);
|
||
|
|
}
|
||
|
|
}
|