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.

427 lines
15 KiB

2 weeks ago
import { _decorator, Component, Node, sp, Tween } from 'cc';
3 weeks ago
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;
2 weeks ago
/** 表现根(heroTemplate;本项目不再挂 prefab/hero 体) */
3 weeks ago
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;
}
/**
2 weeks ago
* 幻魂镜下层:挂到表现根,层级在「品质框」之上。
3 weeks ago
* @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;
}
/**
2 weeks ago
* 幻魂镜上层:挂到表现根最上层。
3 weeks ago
* @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();
}
2 weeks ago
/** 绑定英雄数据(不加载 prefab/hero) */
3 weeks ago
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, []);
}
/**
2 weeks ago
* 设置品质框显隐。
3 weeks ago
* @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 {
2 weeks ago
this._displayNode = this.node?.isValid ? this.node : null;
this._bindAnim();
this._ensureGlyphAnimVisible();
this._ensureShadowVisible();
this._applyQualityFrame();
this.playIdleAnim();
3 weeks ago
}
/** 池化出池不会恢复子节点 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);
2 weeks ago
if (!frame?.isValid) return;
3 weeks ago
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);
};
2 weeks ago
/** 优先挂「动画」节点的 Spine */
3 weeks ago
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();
2 weeks ago
this.anim = null;
if (!node?.isValid) return;
this._qualityFrameVisible = true;
this._restoreQualityFrameForPool(node);
this._detachConfuseMirrorFxFromDisplay(node);
Tween.stopAllByTarget(node);
node.angle = 0;
3 weeks ago
}
/** 回池前剥离幻魂镜节点,避免鬼手随 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;
2 weeks ago
if (!anim?.isValid) return;
3 weeks ago
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);
}
}