/** * @file HlistComp.ts * @description 英雄列表轮播组件(UI 视图层) * * 职责: * 1. 在主页界面以 **5 格轮播** 形式展示英雄图鉴。 * 2. 支持左右切换,用 tween 动画平滑过渡节点位置。 * 3. 点击切换时自动更新当前选中英雄的名称、AP、HP 和技能信息。 * * 关键设计: * - carouselNodes[0..4] 对应 5 个展示位(最左-2 到最右+2), * 中间位 [2] 为当前选中英雄。 * - 切换时:将即将移出屏幕的节点瞬间跳转到另一端,再用 tween 滑入。 * - 切换完成后重排 carouselNodes 数组保持逻辑顺序。 * - iconVisualTokens 按节点独立管理竞态令牌,防止异步动画回调错乱。 * * 依赖: * - HeroInfo / HeroList(heroSet)—— 英雄静态配置与全量英雄 UUID 列表 * - SkillSet / IType(SkillSet)—— 技能配置与类型枚举 */ import { _decorator, Animation, AnimationClip, Button, Event, Label, Node, NodeEventType, Sprite, resources, tween, Vec3 } from "cc"; import { ecs } from "../../../../extensions/oops-plugin-framework/assets/libs/ecs/ECS"; import { CCComp } from "../../../../extensions/oops-plugin-framework/assets/module/common/CCComp"; import { HeroInfo, HeroList } from "../common/config/heroSet"; import { IType, SkillSet } from "../common/config/SkillSet"; import { oops } from "db://oops-framework/core/Oops"; import { mLogger } from "../common/Logger"; const {property, ccclass } = _decorator; /** * HListComp —— 英雄图鉴轮播视图组件 * * 在任务主页展示所有可用英雄,玩家可左右滑动查看: * - 中间位显示完整信息(名称 / AP / HP / 技能列表) * - 两侧位显示缩略 idle 动画 */ @ccclass('HListComp') @ecs.register('HListComp', false) export class HListComp extends CCComp { // ======================== 编辑器绑定节点 ======================== /** 中间位英雄 idle 图标节点 */ @property(Node) hero_icon=null! /** 左侧第 1 位英雄图标 */ @property(Node) phero_icon=null! /** 右侧第 1 位英雄图标 */ @property(Node) nhero_icon=null! /** 左侧第 2 位英雄图标(最远) */ @property(Node) phero1_icon=null! /** 右侧第 2 位英雄图标(最远) */ @property(Node) nhero1_icon=null! /** 攻击力标签节点 */ @property(Node) ap_node=null! /** 生命值标签节点 */ @property(Node) hp_node=null! /** 技能信息容器节点(包含 Line1~Line5 子节点) */ @property(Node) info_node=null! /** 英雄名称标签节点 */ @property(Node) name_node=null! /** 向左切换按钮 */ @property(Node) pre_btn=null! /** 向右切换按钮 */ @property(Node) next_btn=null! // ======================== 运行时状态 ======================== /** 当前选中英雄在 HeroList 中的索引 */ huuid:number=null! /** 当前选中英雄在 HeroList 数组中的下标 */ private currentIndex: number = 0; /** 各图标节点的视觉令牌映射(防止异步动画竞态) */ private iconVisualTokens: Map = new Map(); /** 是否正在播放切换动画(防止快速连点) */ private isAnimating: boolean = false; /** 轮播节点数组,顺序为 [左2, 左1, 中, 右1, 右2] */ private carouselNodes: Node[] = []; /** 5 个固定位置坐标(从场景中读取初始值) */ private fixedPositions: Vec3[] = []; /** 调试日志开关 */ debugMode: boolean = false; onLoad() { // 绑定左右切换按钮事件 this.pre_btn?.on(NodeEventType.TOUCH_END, this.onPreClick, this); this.next_btn?.on(NodeEventType.TOUCH_END, this.onNextClick, this); } start() { // 初始化轮播节点数组和固定位置 if (this.phero1_icon && this.phero_icon && this.hero_icon && this.nhero_icon && this.nhero1_icon) { this.carouselNodes = [this.phero1_icon, this.phero_icon, this.hero_icon, this.nhero_icon, this.nhero1_icon]; this.fixedPositions = this.carouselNodes.map(n => n.position.clone()); } // 设置初始选中并加载所有位置的英雄动画 if (HeroList && HeroList.length > 0) { this.currentIndex = 0; this.initAllNodes(); this.updateHeroInfo(); } } // ======================== 切换逻辑 ======================== /** * 向左切换(查看上一个英雄): * 1. currentIndex 左移。 * 2. 最右节点 n4 瞬间跳到最左位置,加载新英雄动画。 * 3. 所有节点 tween 向右滑动一格。 * 4. 动画完成后重排 carouselNodes 数组。 */ private onPreClick() { if (!HeroList || HeroList.length === 0 || this.isAnimating || this.carouselNodes.length < 5) return; this.isAnimating = true; this.currentIndex = (this.currentIndex - 1 + HeroList.length) % HeroList.length; const [n0, n1, n2, n3, n4] = this.carouselNodes; // n4 瞬间跳至最左位置,准备滑入 n4.setPosition(new Vec3(this.fixedPositions[0].x, n4.position.y, n4.position.z)); this.updateNodeAnimationByOffset(n4, -2); this.updateHeroInfo(); // 所有节点向右滑动一格 tween(n0).to(0.2, { position: new Vec3(this.fixedPositions[1].x, n0.position.y, n0.position.z) }).start(); tween(n1).to(0.2, { position: new Vec3(this.fixedPositions[2].x, n1.position.y, n1.position.z) }).start(); tween(n2).to(0.2, { position: new Vec3(this.fixedPositions[3].x, n2.position.y, n2.position.z) }).start(); tween(n3).to(0.2, { position: new Vec3(this.fixedPositions[4].x, n3.position.y, n3.position.z) }) .call(() => { // 重排数组:n4 成为新的最左节点 this.carouselNodes = [n4, n0, n1, n2, n3]; this.isAnimating = false; }) .start(); } /** * 向右切换(查看下一个英雄): * 1. currentIndex 右移。 * 2. 最左节点 n0 瞬间跳到最右位置,加载新英雄动画。 * 3. 所有节点 tween 向左滑动一格。 * 4. 动画完成后重排 carouselNodes 数组。 */ private onNextClick() { if (!HeroList || HeroList.length === 0 || this.isAnimating || this.carouselNodes.length < 5) return; this.isAnimating = true; this.currentIndex = (this.currentIndex + 1) % HeroList.length; const [n0, n1, n2, n3, n4] = this.carouselNodes; // n0 瞬间跳至最右位置,准备滑入 n0.setPosition(new Vec3(this.fixedPositions[4].x, n0.position.y, n0.position.z)); this.updateNodeAnimationByOffset(n0, 2); this.updateHeroInfo(); // 所有节点向左滑动一格 tween(n1).to(0.2, { position: new Vec3(this.fixedPositions[0].x, n1.position.y, n1.position.z) }).start(); tween(n2).to(0.2, { position: new Vec3(this.fixedPositions[1].x, n2.position.y, n2.position.z) }).start(); tween(n3).to(0.2, { position: new Vec3(this.fixedPositions[2].x, n3.position.y, n3.position.z) }).start(); tween(n4).to(0.2, { position: new Vec3(this.fixedPositions[3].x, n4.position.y, n4.position.z) }) .call(() => { // 重排数组:n0 成为新的最右节点 this.carouselNodes = [n1, n2, n3, n4, n0]; this.isAnimating = false; }) .start(); } // ======================== 数据查询 ======================== /** * 根据偏移量获取英雄 UUID。 * @param offset 相对于当前选中英雄的偏移(-2, -1, 0, 1, 2) * @returns HeroList 中对应位置的英雄 UUID */ private getHeroUuid(offset: number): number { const len = HeroList.length; return HeroList[(this.currentIndex + offset + len * 5) % len]; } /** * 按偏移量更新指定节点的英雄动画。 * @param node 目标图标节点 * @param offset 偏移量 */ private updateNodeAnimationByOffset(node: Node, offset: number) { const uuid = this.getHeroUuid(offset); this.updateHeroAnimation(node, uuid); } /** 更新当前选中英雄的详细信息(名称、AP、HP、技能列表) */ private updateHeroInfo() { this.huuid = this.getHeroUuid(0); const hero = HeroInfo[this.huuid]; if (!hero) return; this.setLabelText(this.name_node, hero.name); this.setLabelText(this.ap_node, `攻击力: ${hero.ap}`); this.setLabelText(this.hp_node, `生命值: ${hero.hp}`); this.updateSkillInfo(hero); } /** 初始化 5 个轮播位的英雄动画 */ private initAllNodes() { if (this.carouselNodes.length < 5) return; this.updateNodeAnimationByOffset(this.carouselNodes[0], -2); this.updateNodeAnimationByOffset(this.carouselNodes[1], -1); this.updateNodeAnimationByOffset(this.carouselNodes[2], 0); this.updateNodeAnimationByOffset(this.carouselNodes[3], 1); this.updateNodeAnimationByOffset(this.carouselNodes[4], 2); } // ======================== UI 工具 ======================== /** * 安全设置 Label 文本 * @param node 标签所在节点 * @param text 文本内容 */ private setLabelText(node: Node, text: string) { if (!node) return; const label = node.getComponent(Label) || node.getComponentInChildren(Label); if (label) { label.string = text; } } // ======================== 英雄动画 ======================== /** * 为指定节点加载并播放英雄 idle 动画。 * 使用 iconVisualTokens 做节点级竞态保护。 * * @param node 图标节点 * @param uuid 英雄 UUID */ private updateHeroAnimation(node: Node, uuid: number) { if (!node) return; const sprite = node.getComponent(Sprite) || node.getComponentInChildren(Sprite); if (sprite) sprite.spriteFrame = null; const hero = HeroInfo[uuid]; if (!hero) return; const anim = node.getComponent(Animation) || node.addComponent(Animation); this.clearAnimationClips(anim); // 递增该节点的视觉令牌 let token = (this.iconVisualTokens.get(node) || 0) + 1; this.iconVisualTokens.set(node, token); const path = `game/heros/hero/${hero.path}/idle`; resources.load(path, AnimationClip, (err, clip) => { if (err || !clip) { mLogger.log(this.debugMode, "HListComp", `load hero animation failed ${uuid}`, err); return; } // 竞态保护:令牌不匹配则丢弃 if (token !== this.iconVisualTokens.get(node)) { return; } this.clearAnimationClips(anim); anim.addClip(clip); anim.play("idle"); }); } /** 移除 Animation 上的全部 clip(倒序遍历避免索引偏移) */ private clearAnimationClips(anim: Animation) { const clips = anim.clips; if (clips && clips.length > 0) { for (let i = clips.length - 1; i >= 0; i--) { const clip = clips[i]; if (clip) anim.removeClip(clip, true); } } } /** * 递归查找子节点(按名称) * @param root 起始节点 * @param name 目标节点名 */ private findNodeByName(root: Node, name: string): Node | null { if (!root) return null; if (root.name === name) return root; for (let i = 0; i < root.children.length; i++) { const res = this.findNodeByName(root.children[i], name); if (res) return res; } return null; } // ======================== 技能信息 ======================== /** * 更新技能信息面板: * 遍历英雄的技能列表,为 Line1~Line5 节点填充技能名、等级、CD、描述。 * 同时根据技能类型(近战 / 远程 / 辅助)切换对应图标。 * * @param hero 英雄配置数据(含 skills 字段) */ private updateSkillInfo(hero: any) { if (!this.info_node) return; const skills = Object.values(hero.skills || {}); for (let i = 1; i <= 5; i++) { let line = this.findNodeByName(this.info_node, `Line${i}`) || this.findNodeByName(this.info_node, `line${i}`); if (!line) continue; const skill: any = skills[i - 1]; if (skill) { line.active = true; const skillId = skill.uuid; const config = SkillSet[skillId]; // 拼接技能信息文本 const text = config ? `${config.name} Lv.${skill.lv} CD:${skill.cd}s ${config.info}` : `未知技能 CD:${skill.cd}s`; const noteNode = this.findNodeByName(line, "note"); const label = noteNode?.getComponent(Label) || noteNode?.getComponentInChildren(Label) || line.getComponentInChildren(Label); if (label) { label.string = text; } // 切换技能类型图标 this.updateLineTypeIcon(line, config?.IType); } else { line.active = false; } } } /** * 更新技能行的类型图标(互斥显示): * - Melee → 近战图标 * - remote → 远程图标 * - support → 辅助图标 * * @param line 技能行节点 * @param iType 技能类型枚举 */ private updateLineTypeIcon(line: Node, iType?: IType) { const meleeNode = this.findNodeByName(line, "Melee"); const remoteNode = this.findNodeByName(line, "remote"); const supportNode = this.findNodeByName(line, "support"); if (meleeNode) meleeNode.active = iType === IType.Melee; if (remoteNode) remoteNode.active = iType === IType.remote; if (supportNode) supportNode.active = iType === IType.support; } /** ECS 组件移除时销毁节点 */ reset() { this.node.destroy(); } }