Files
pixelheros/assets/script/game/map/HlistComp.ts
walkpan e880613f8f docs: 为游戏地图模块添加详细的代码注释
为游戏地图模块的脚本文件添加全面的注释,说明每个组件的职责、关键设计、依赖关系和使用方式。注释覆盖了英雄信息面板、技能卡槽位管理器、排行榜弹窗、卡牌控制器、背景滚动组件等核心功能模块,提高了代码的可读性和维护性。

同时修复了英雄预制体的激活状态和技能效果预制体的尺寸参数。
2026-04-07 19:00:30 +08:00

372 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file HlistComp.ts
* @description 英雄列表轮播组件UI 视图层)
*
* 职责:
* 1. 在主页界面以 **5 格轮播** 形式展示英雄图鉴。
* 2. 支持左右切换,用 tween 动画平滑过渡节点位置。
* 3. 点击切换时自动更新当前选中英雄的名称、AP、HP 和技能信息。
*
* 关键设计:
* - carouselNodes[0..4] 对应 5 个展示位(最左-2 到最右+2
* 中间位 [2] 为当前选中英雄。
* - 切换时:将即将移出屏幕的节点瞬间跳转到另一端,再用 tween 滑入。
* - 切换完成后重排 carouselNodes 数组保持逻辑顺序。
* - iconVisualTokens 按节点独立管理竞态令牌,防止异步动画回调错乱。
*
* 依赖:
* - HeroInfo / HeroListheroSet—— 英雄静态配置与全量英雄 UUID 列表
* - SkillSet / ITypeSkillSet—— 技能配置与类型枚举
*/
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<Node, number> = 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();
}
}