From 2f27bb703571c6563f17528f8ae88c0549c661f2 Mon Sep 17 00:00:00 2001 From: panw Date: Wed, 27 May 2026 15:24:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(map):=20=E9=87=8D=E6=9E=84=E8=8B=B1?= =?UTF-8?q?=E9=9B=84=E5=9B=BE=E9=89=B4=E9=A1=B5=E9=9D=A2=EF=BC=8C=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E5=AE=8C=E6=95=B4=E7=9A=84=E8=8B=B1=E9=9B=84=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E5=B1=95=E7=A4=BA=E4=B8=8E=E8=AF=A6=E6=83=85=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 重写HerosListComp组件,实现卡片动态生成、选中高亮、详情更新逻辑 2. 完善CardLiteComp组件,支持英雄卡渲染、点击交互与动画加载 3. 清理冗余的预制体绑定代码,修复异步加载竞态问题 4. 添加详细的日志与注释,优化可维护性 --- assets/resources/gui/element/heros.prefab | 535 ++-------------------- assets/script/game/map/CardLiteComp.ts | 415 +++++++++++++++-- assets/script/game/map/HerosListComp.ts | 286 ++++++++++-- 3 files changed, 650 insertions(+), 586 deletions(-) diff --git a/assets/resources/gui/element/heros.prefab b/assets/resources/gui/element/heros.prefab index d197a1b8..df491f67 100644 --- a/assets/resources/gui/element/heros.prefab +++ b/assets/resources/gui/element/heros.prefab @@ -31,17 +31,17 @@ "_active": true, "_components": [ { - "__id__": 375 + "__id__": 343 }, { - "__id__": 377 + "__id__": 345 }, { - "__id__": 379 + "__id__": 347 } ], "_prefab": { - "__id__": 381 + "__id__": 349 }, "_lpos": { "__type__": "cc.Vec3", @@ -6536,14 +6536,14 @@ "_active": true, "_components": [ { - "__id__": 370 + "__id__": 338 }, { - "__id__": 372 + "__id__": 340 } ], "_prefab": { - "__id__": 374 + "__id__": 342 }, "_lpos": { "__type__": "cc.Vec3", @@ -6593,20 +6593,20 @@ "_active": true, "_components": [ { - "__id__": 363 + "__id__": 331 }, { - "__id__": 365 + "__id__": 333 }, { "__id__": 310 }, { - "__id__": 367 + "__id__": 335 } ], "_prefab": { - "__id__": 369 + "__id__": 337 }, "_lpos": { "__type__": "cc.Vec3", @@ -6666,7 +6666,7 @@ } ], "_prefab": { - "__id__": 362 + "__id__": 330 }, "_lpos": { "__type__": "cc.Vec3", @@ -7010,34 +7010,21 @@ "_parent": { "__id__": 313 }, - "_children": [ + "_children": [], + "_active": true, + "_components": [ { "__id__": 323 }, { - "__id__": 331 + "__id__": 325 }, { - "__id__": 339 - }, - { - "__id__": 347 - } - ], - "_active": true, - "_components": [ - { - "__id__": 355 - }, - { - "__id__": 357 - }, - { - "__id__": 359 + "__id__": 327 } ], "_prefab": { - "__id__": 361 + "__id__": 329 }, "_lpos": { "__type__": "cc.Vec3", @@ -7273,454 +7260,6 @@ "targetOverrides": null, "nestedPrefabInstanceRoots": null }, - { - "__type__": "cc.Node", - "_objFlags": 0, - "_parent": { - "__id__": 312 - }, - "_prefab": { - "__id__": 324 - }, - "__editorExtras__": {} - }, - { - "__type__": "cc.PrefabInfo", - "root": { - "__id__": 323 - }, - "asset": { - "__uuid__": "b8313fa7-28e5-4d92-9d64-a1e0ecb040a8", - "__expectedType__": "cc.Prefab" - }, - "fileId": "24rlgXRJ9AHLGpMW+aYyEx", - "instance": { - "__id__": 325 - }, - "targetOverrides": null - }, - { - "__type__": "cc.PrefabInstance", - "fileId": "02nuyp0OlFMrhOJh7vihYl", - "prefabRootNode": { - "__id__": 1 - }, - "mountedChildren": [], - "mountedComponents": [], - "propertyOverrides": [ - { - "__id__": 326 - }, - { - "__id__": 328 - }, - { - "__id__": 329 - }, - { - "__id__": 330 - } - ], - "removedComponents": [] - }, - { - "__type__": "CCPropertyOverrideInfo", - "targetInfo": { - "__id__": 327 - }, - "propertyPath": [ - "_name" - ], - "value": "cardlite" - }, - { - "__type__": "cc.TargetInfo", - "localID": [ - "24rlgXRJ9AHLGpMW+aYyEx" - ] - }, - { - "__type__": "CCPropertyOverrideInfo", - "targetInfo": { - "__id__": 327 - }, - "propertyPath": [ - "_lpos" - ], - "value": { - "__type__": "cc.Vec3", - "x": -260, - "y": -120, - "z": 0 - } - }, - { - "__type__": "CCPropertyOverrideInfo", - "targetInfo": { - "__id__": 327 - }, - "propertyPath": [ - "_lrot" - ], - "value": { - "__type__": "cc.Quat", - "x": 0, - "y": 0, - "z": 0, - "w": 1 - } - }, - { - "__type__": "CCPropertyOverrideInfo", - "targetInfo": { - "__id__": 327 - }, - "propertyPath": [ - "_euler" - ], - "value": { - "__type__": "cc.Vec3", - "x": 0, - "y": 0, - "z": 0 - } - }, - { - "__type__": "cc.Node", - "_objFlags": 0, - "_parent": { - "__id__": 312 - }, - "_prefab": { - "__id__": 332 - }, - "__editorExtras__": {} - }, - { - "__type__": "cc.PrefabInfo", - "root": { - "__id__": 331 - }, - "asset": { - "__uuid__": "b8313fa7-28e5-4d92-9d64-a1e0ecb040a8", - "__expectedType__": "cc.Prefab" - }, - "fileId": "24rlgXRJ9AHLGpMW+aYyEx", - "instance": { - "__id__": 333 - }, - "targetOverrides": null - }, - { - "__type__": "cc.PrefabInstance", - "fileId": "82qCKYiEZLkJaOe4tyrOBe", - "prefabRootNode": { - "__id__": 1 - }, - "mountedChildren": [], - "mountedComponents": [], - "propertyOverrides": [ - { - "__id__": 334 - }, - { - "__id__": 336 - }, - { - "__id__": 337 - }, - { - "__id__": 338 - } - ], - "removedComponents": [] - }, - { - "__type__": "CCPropertyOverrideInfo", - "targetInfo": { - "__id__": 335 - }, - "propertyPath": [ - "_name" - ], - "value": "cardlite" - }, - { - "__type__": "cc.TargetInfo", - "localID": [ - "24rlgXRJ9AHLGpMW+aYyEx" - ] - }, - { - "__type__": "CCPropertyOverrideInfo", - "targetInfo": { - "__id__": 335 - }, - "propertyPath": [ - "_lpos" - ], - "value": { - "__type__": "cc.Vec3", - "x": -90, - "y": -120, - "z": 0 - } - }, - { - "__type__": "CCPropertyOverrideInfo", - "targetInfo": { - "__id__": 335 - }, - "propertyPath": [ - "_lrot" - ], - "value": { - "__type__": "cc.Quat", - "x": 0, - "y": 0, - "z": 0, - "w": 1 - } - }, - { - "__type__": "CCPropertyOverrideInfo", - "targetInfo": { - "__id__": 335 - }, - "propertyPath": [ - "_euler" - ], - "value": { - "__type__": "cc.Vec3", - "x": 0, - "y": 0, - "z": 0 - } - }, - { - "__type__": "cc.Node", - "_objFlags": 0, - "_parent": { - "__id__": 312 - }, - "_prefab": { - "__id__": 340 - }, - "__editorExtras__": {} - }, - { - "__type__": "cc.PrefabInfo", - "root": { - "__id__": 339 - }, - "asset": { - "__uuid__": "b8313fa7-28e5-4d92-9d64-a1e0ecb040a8", - "__expectedType__": "cc.Prefab" - }, - "fileId": "24rlgXRJ9AHLGpMW+aYyEx", - "instance": { - "__id__": 341 - }, - "targetOverrides": null - }, - { - "__type__": "cc.PrefabInstance", - "fileId": "623HfkMl9GcIckg94/cCZ3", - "prefabRootNode": { - "__id__": 1 - }, - "mountedChildren": [], - "mountedComponents": [], - "propertyOverrides": [ - { - "__id__": 342 - }, - { - "__id__": 344 - }, - { - "__id__": 345 - }, - { - "__id__": 346 - } - ], - "removedComponents": [] - }, - { - "__type__": "CCPropertyOverrideInfo", - "targetInfo": { - "__id__": 343 - }, - "propertyPath": [ - "_name" - ], - "value": "cardlite" - }, - { - "__type__": "cc.TargetInfo", - "localID": [ - "24rlgXRJ9AHLGpMW+aYyEx" - ] - }, - { - "__type__": "CCPropertyOverrideInfo", - "targetInfo": { - "__id__": 343 - }, - "propertyPath": [ - "_lpos" - ], - "value": { - "__type__": "cc.Vec3", - "x": 80, - "y": -120, - "z": 0 - } - }, - { - "__type__": "CCPropertyOverrideInfo", - "targetInfo": { - "__id__": 343 - }, - "propertyPath": [ - "_lrot" - ], - "value": { - "__type__": "cc.Quat", - "x": 0, - "y": 0, - "z": 0, - "w": 1 - } - }, - { - "__type__": "CCPropertyOverrideInfo", - "targetInfo": { - "__id__": 343 - }, - "propertyPath": [ - "_euler" - ], - "value": { - "__type__": "cc.Vec3", - "x": 0, - "y": 0, - "z": 0 - } - }, - { - "__type__": "cc.Node", - "_objFlags": 0, - "_parent": { - "__id__": 312 - }, - "_prefab": { - "__id__": 348 - }, - "__editorExtras__": {} - }, - { - "__type__": "cc.PrefabInfo", - "root": { - "__id__": 347 - }, - "asset": { - "__uuid__": "b8313fa7-28e5-4d92-9d64-a1e0ecb040a8", - "__expectedType__": "cc.Prefab" - }, - "fileId": "24rlgXRJ9AHLGpMW+aYyEx", - "instance": { - "__id__": 349 - }, - "targetOverrides": null - }, - { - "__type__": "cc.PrefabInstance", - "fileId": "164auV8KBJbKiVegqqYH2a", - "prefabRootNode": { - "__id__": 1 - }, - "mountedChildren": [], - "mountedComponents": [], - "propertyOverrides": [ - { - "__id__": 350 - }, - { - "__id__": 352 - }, - { - "__id__": 353 - }, - { - "__id__": 354 - } - ], - "removedComponents": [] - }, - { - "__type__": "CCPropertyOverrideInfo", - "targetInfo": { - "__id__": 351 - }, - "propertyPath": [ - "_name" - ], - "value": "cardlite" - }, - { - "__type__": "cc.TargetInfo", - "localID": [ - "24rlgXRJ9AHLGpMW+aYyEx" - ] - }, - { - "__type__": "CCPropertyOverrideInfo", - "targetInfo": { - "__id__": 351 - }, - "propertyPath": [ - "_lpos" - ], - "value": { - "__type__": "cc.Vec3", - "x": 250, - "y": -120, - "z": 0 - } - }, - { - "__type__": "CCPropertyOverrideInfo", - "targetInfo": { - "__id__": 351 - }, - "propertyPath": [ - "_lrot" - ], - "value": { - "__type__": "cc.Quat", - "x": 0, - "y": 0, - "z": 0, - "w": 1 - } - }, - { - "__type__": "CCPropertyOverrideInfo", - "targetInfo": { - "__id__": 351 - }, - "propertyPath": [ - "_euler" - ], - "value": { - "__type__": "cc.Vec3", - "x": 0, - "y": 0, - "z": 0 - } - }, { "__type__": "cc.UITransform", "_name": "", @@ -7731,7 +7270,7 @@ }, "_enabled": true, "__prefab": { - "__id__": 356 + "__id__": 324 }, "_contentSize": { "__type__": "cc.Size", @@ -7759,7 +7298,7 @@ }, "_enabled": true, "__prefab": { - "__id__": 358 + "__id__": 326 }, "_alignFlags": 41, "_target": null, @@ -7795,7 +7334,7 @@ }, "_enabled": true, "__prefab": { - "__id__": 360 + "__id__": 328 }, "_resizeMode": 0, "_layoutType": 3, @@ -7859,7 +7398,7 @@ }, "_enabled": true, "__prefab": { - "__id__": 364 + "__id__": 332 }, "_contentSize": { "__type__": "cc.Size", @@ -7887,7 +7426,7 @@ }, "_enabled": false, "__prefab": { - "__id__": 366 + "__id__": 334 }, "_customMaterial": null, "_srcBlendFactor": 2, @@ -7932,7 +7471,7 @@ }, "_enabled": true, "__prefab": { - "__id__": 368 + "__id__": 336 }, "_alignFlags": 45, "_target": null, @@ -7981,7 +7520,7 @@ }, "_enabled": true, "__prefab": { - "__id__": 371 + "__id__": 339 }, "_contentSize": { "__type__": "cc.Size", @@ -8009,7 +7548,7 @@ }, "_enabled": true, "__prefab": { - "__id__": 373 + "__id__": 341 }, "_alignFlags": 45, "_target": null, @@ -8058,7 +7597,7 @@ }, "_enabled": true, "__prefab": { - "__id__": 376 + "__id__": 344 }, "_contentSize": { "__type__": "cc.Size", @@ -8086,7 +7625,7 @@ }, "_enabled": true, "__prefab": { - "__id__": 378 + "__id__": 346 }, "_alignFlags": 45, "_target": null, @@ -8122,7 +7661,7 @@ }, "_enabled": true, "__prefab": { - "__id__": 380 + "__id__": 348 }, "hero_icon": { "__id__": 95 @@ -8149,6 +7688,10 @@ "__uuid__": "b8313fa7-28e5-4d92-9d64-a1e0ecb040a8", "__expectedType__": "cc.Prefab" }, + "lv_node": { + "__id__": 130 + }, + "type_node": null, "_id": "" }, { @@ -8167,18 +7710,6 @@ "instance": null, "targetOverrides": null, "nestedPrefabInstanceRoots": [ - { - "__id__": 347 - }, - { - "__id__": 339 - }, - { - "__id__": 331 - }, - { - "__id__": 323 - }, { "__id__": 130 } diff --git a/assets/script/game/map/CardLiteComp.ts b/assets/script/game/map/CardLiteComp.ts index 6594f48f..a606b1a0 100644 --- a/assets/script/game/map/CardLiteComp.ts +++ b/assets/script/game/map/CardLiteComp.ts @@ -1,74 +1,415 @@ /** - + * @file CardLiteComp.ts + * @description 英雄图鉴卡预制体组件(UI 视图层) + * + * 职责: + * 1. 管理英雄图鉴中单张卡牌的 **显示**(名称、图标动画、费用、卡池等级、等级)。 + * 2. 接收 CardConfig 数据并渲染对应卡面(英雄 / 技能 / 特殊卡)。 + * 3. 点击卡牌时通过事件通知父组件(HerosListComp)切换选中英雄详情。 + * + * 关键设计: + * - 轻量版 CardComp,无拖拽使用、无锁定、无费用扣除等交互。 + * - 英雄卡图标使用 AnimationClip 动态加载 idle 动画;非英雄卡使用静态图标。 + * - 通过 iconVisualToken 防止异步加载竞态。 + * + * 依赖: + * - CardConfig / CardType / CKind 等卡牌数据结构(CardSet) + * - HeroInfo(heroSet)—— 用于渲染英雄卡面信息 + * - SkillSet(SkillSet)—— 用于渲染技能卡图标 + * - smc —— 全局单例,访问图标图集缓存 */ import { mLogger } from "../common/Logger"; -import { _decorator, Animation, AnimationClip, EventTouch, Label, Node, NodeEventType, Sprite, SpriteAtlas, Tween, tween, UIOpacity, Vec3, resources, Light, UITransform, Widget } from "cc"; +import { _decorator, Animation, AnimationClip, EventTouch, Label, Node, NodeEventType, Sprite, SpriteAtlas, Tween, tween, UIOpacity, Vec3, resources, UITransform, Widget } 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 { CardConfig, CardType, SpecialRefreshCardList, SpecialUpgradeCardList, CKind, CardPoolList } from "../common/config/CardSet"; import { HeroInfo } from "../common/config/heroSet"; import { SkillSet } from "../common/config/SkillSet"; -import { GameEvent } from "../common/config/GameEvent"; -import { oops } from "db://oops-framework/core/Oops"; -import { smc } from "../common/SingletonModuleComp"; - -import { UIID } from "../common/config/GameUIConfig"; -import { HeroAttrsComp } from "../hero/HeroAttrsComp"; -import { TalentType } from "../common/config/TalentSet"; import { getLvColor } from "../common/config/GameSet"; -import { MissionEconomy } from "./MissionEconomy"; - - +import { smc } from "../common/SingletonModuleComp"; const { ccclass, property } = _decorator; /** * CardLiteComp —— 单张卡牌简单视图组件 * - + * 挂载在英雄图鉴卡预制体上,由 HerosListComp 实例化并管理。 + * 负责单卡的渲染与点击选择。 */ @ccclass('CardLiteComp') @ecs.register('CardLiteComp', false) export class CardLiteComp extends CCComp { - /** 是否开启调试日志 */ private debugMode: boolean = true; // ======================== 编辑器绑定节点 ======================== - /** 锁定态图标节点(显示时表示本槽位锁定) */ - @property(Node) - name_node=null! - /** 卡牌图标节点(英雄动画 / 技能图标) */ + name_node: Node = null! @property(Node) - icon_node=null! - /** 费用显示节点 */ + icon_node: Node = null! @property(Node) - cost_node=null! - /** 卡牌种类标识节点(如近战 / 远程 / 辅助等分类子节点的容器) */ + cost_node: Node = null! @property(Node) - Ckind_node=null! - /** 卡牌背景底框节点(按卡池等级切换子节点显示) */ + Ckind_node: Node = null! @property(Node) - BG_node=null! - + BG_node: Node = null! @property(Node) - pool_lv_node=null! //英雄对应的卡池等级 - + pool_lv_node: Node = null! @property(Label) - lvl_node: Label = null! //英雄本身的等级 + lvl_node: Label = null! // ======================== 运行时状态 ======================== - /** 当前卡牌的金币费用 */ - card_cost:number=0 - /** 当前卡牌类型(英雄 / 技能 / 特殊升级 / 特殊刷新) */ - card_type:CardType=CardType.Hero - /** 当前卡牌的唯一标识 UUID */ - card_uuid:number=0 - + card_cost: number = 0 + card_type: CardType = CardType.Hero + card_uuid: number = 0 private cardData: CardConfig | null = null; - + private iconVisualToken: number = 0; + private opacityComp: UIOpacity | null = null; + + /** 点击回调(由外部 HerosListComp 设置) */ + onClickCallback: ((comp: CardLiteComp) => void) | null = null; + + // ======================== 生命周期 ======================== + + onLoad() { + this.bindEvents(); + this.opacityComp = this.node.getComponent(UIOpacity) || this.node.addComponent(UIOpacity); + this.opacityComp.opacity = 255; + if (!this.cardData) { + this.applyEmptyUI(); + } + } + + onDestroy() { + super.onDestroy(); + this.unbindEvents(); + } + + start() { + this.node.active = true; + } + + // ======================== 对外接口 ======================== + + /** + * 设置卡牌数据并渲染。 + * @param data 卡牌配置数据 + */ + setData(data: CardConfig) { + if (!data) return; + this.cardData = data; + this.card_uuid = data.uuid; + this.card_type = data.type; + this.card_cost = data.cost ?? 0; + this.node.active = true; + this.applyCardUI(); + mLogger.log(this.debugMode, "CardLiteComp", "setData", { + uuid: this.card_uuid, + type: this.card_type, + cost: this.card_cost + }); + } + + /** + * 便捷方法:通过英雄 UUID 和卡池等级直接设置英雄卡。 + * 自动从 CardPoolList 查找匹配的卡牌配置。 + * @param uuid 英雄 UUID + * @param poolLv 卡池等级(默认 1) + */ + setHeroByUuid(uuid: number, poolLv: number = 1) { + const card = CardPoolList.find(c => c.uuid === uuid && c.pool_lv === poolLv); + if (card) { + this.setData(card); + return; + } + const hero = HeroInfo[uuid]; + if (hero) { + this.setData({ + uuid: hero.uuid, + type: CardType.Hero, + cost: 5, + weight: 25, + pool_lv: poolLv as any, + kind: CKind.Hero, + hero_lv: hero.lv || 1 + }); + } + } + + /** 查询当前是否有卡牌数据 */ + hasCard(): boolean { + return !!this.cardData; + } + + /** 获取当前卡牌数据 */ + getCardData(): CardConfig | null { + return this.cardData; + } + + /** + * 清空卡牌数据并重置 UI。 + */ + clear() { + this.cardData = null; + this.card_uuid = 0; + this.card_cost = 0; + this.card_type = CardType.Hero; + this.applyEmptyUI(); + this.node.active = false; + } + + // ======================== 事件绑定 ======================== + + private bindEvents() { + this.node.on(NodeEventType.TOUCH_END, this.onCardClick, this); + } + + private unbindEvents() { + if (this.node && this.node.isValid) { + this.node.off(NodeEventType.TOUCH_END, this.onCardClick, this); + } + } + + /** 点击卡牌:触发回调通知父组件 */ + private onCardClick(event: EventTouch) { + if (!this.cardData) return; + event.propagationStopped = true; + if (this.onClickCallback) { + this.onClickCallback(this); + } + mLogger.log(this.debugMode, "CardLiteComp", "card clicked", { + uuid: this.card_uuid, + type: this.card_type + }); + } + + // ======================== UI 渲染 ======================== + + /** + * 根据当前 cardData 渲染卡面。 + * + * 渲染逻辑根据卡牌类型分三路: + * 1. 英雄卡:显示英雄名、等级、idle 动画。 + * 2. 技能卡:显示技能名、静态图标。 + * 3. 特殊卡:显示卡名、静态图标。 + * + * 同时根据 pool_lv 切换背景底框,根据 kind 切换种类标识。 + */ + private applyCardUI() { + if (!this.cardData) { + this.applyEmptyUI(); + return; + } + + this.iconVisualToken += 1; + if (this.opacityComp) this.opacityComp.opacity = 255; + + mLogger.log(this.debugMode, "CardLiteComp", "applyCardUI nodes", { + uuid: this.card_uuid, + hasName: !!this.name_node, + hasIcon: !!this.icon_node, + hasCost: !!this.cost_node, + hasBG: !!this.BG_node, + hasPoolLv: !!this.pool_lv_node, + hasLvl: !!this.lvl_node, + bgChildren: this.BG_node?.children.map(c => c.name) || [], + poolChildren: this.pool_lv_node?.children.map(c => c.name) || [], + nodeActive: this.node.active, + nodeScale: this.node.scale.toString(), + nodePos: this.node.position.toString(), + parentName: this.node.parent?.name || "none", + }); + + const kindName = CKind[this.cardData.kind]; + + if (this.BG_node) { + this.BG_node.children.forEach(child => { + child.active = (child.name === kindName); + }); + } + + const cardLvStr = `lv${this.cardData.pool_lv}`; + if (this.pool_lv_node) { + this.pool_lv_node.children.forEach(child => { + child.active = (child.name === cardLvStr); + }); + } + + if (this.card_type === CardType.Hero) { + const hero = HeroInfo[this.card_uuid]; + const heroLv = Math.max(1, this.cardData.hero_lv ?? hero?.lv ?? 1); + this.setLabel(this.name_node, `${hero?.name || ""}`); + if (this.lvl_node) { + this.lvl_node.string = `Lv.${heroLv}`; + this.lvl_node.color = getLvColor(heroLv); + this.lvl_node.node.active = true; + } + } else if (this.card_type === CardType.Skill) { + if (this.lvl_node) this.lvl_node.node.active = false; + const skill = SkillSet[this.card_uuid]; + const skillCard = CardPoolList.find(c => c.uuid === this.card_uuid); + const card_lv = Math.max(1, Math.floor(this.cardData.card_lv ?? 1)); + const spSuffix = card_lv >= 2 ? "★".repeat(card_lv - 1) : ""; + this.setLabel(this.name_node, `${spSuffix}${skillCard?.name || skill?.name || ""}${spSuffix}`); + } else { + if (this.lvl_node) this.lvl_node.node.active = false; + const specialCard = this.card_type === CardType.SpecialUpgrade + ? SpecialUpgradeCardList[this.card_uuid] + : SpecialRefreshCardList[this.card_uuid]; + const card_lv = Math.max(1, Math.floor(this.cardData.card_lv ?? 1)); + const spSuffix = card_lv >= 2 ? "★".repeat(card_lv - 1) : ""; + this.setLabel(this.name_node, `${spSuffix}${specialCard?.name || ""}${spSuffix}`); + } + + if (this.cost_node) { + this.cost_node.active = true; + const numNode = this.cost_node.getChildByName("num"); + if (numNode) { + this.setLabel(numNode, `${this.card_cost}`); + } + } + + if (this.icon_node) { + const iconNode = this.icon_node; + if (this.card_type === CardType.Hero) { + iconNode.setScale(new Vec3(-1.5, 1.5, 1)); + this.updateHeroAnimation(iconNode, this.card_uuid, this.iconVisualToken); + return; + } + iconNode.setScale(new Vec3(1, 1, 1)); + this.clearIconAnimation(iconNode); + const iconId = this.resolveCardIconId(this.card_type, this.card_uuid); + if (iconId) { + this.updateIcon(iconNode, iconId); + } else { + const sprite = iconNode?.getComponent(Sprite) || iconNode?.getComponentInChildren(Sprite); + if (sprite) sprite.spriteFrame = null; + } + } + } + + /** + * 渲染空槽状态:清空名称、费用、等级、图标等。 + */ + private applyEmptyUI() { + this.iconVisualToken += 1; + this.setLabel(this.name_node, ""); + if (this.cost_node) { + const numNode = this.cost_node.getChildByName("num"); + if (numNode) { + this.setLabel(numNode, ""); + } + this.cost_node.active = false; + } + if (this.lvl_node) this.lvl_node.node.active = false; + if (this.BG_node) { + this.BG_node.children.forEach(child => child.active = false); + } + if (this.pool_lv_node) { + this.pool_lv_node.children.forEach(child => child.active = false); + } + if (this.icon_node) { + (this.icon_node as Node).setScale(new Vec3(1, 1, 1)); + this.clearIconAnimation(this.icon_node as Node); + const sprite = this.icon_node?.getComponent(Sprite) || this.icon_node?.getComponentInChildren(Sprite); + if (sprite) sprite.spriteFrame = null; + } + } + + // ======================== 动画 ======================== + + /** + * 入场动画:先缩小再弹大再回归正常比例。 + */ + playRefreshAnim() { + Tween.stopAllByTarget(this.node); + this.node.setScale(new Vec3(0.92, 0.92, 1)); + tween(this.node) + .to(0.08, { scale: new Vec3(1.06, 1.06, 1) }) + .to(0.1, { scale: new Vec3(1, 1, 1) }) + .start(); + } + + // ======================== 工具方法 ======================== + + /** + * 安全设置文本,兼容节点上或子节点上的 Label。 + */ + private setLabel(node: Node | null, value: string) { + if (!node) return; + const label = node.getComponent(Label) || node.getComponentInChildren(Label); + if (label) label.string = value; + } + + /** + * 更新图标:从全局缓存图集中获取对应帧。 + */ + private updateIcon(node: Node, iconId: string) { + if (!node || !iconId) return; + const sprite = node.getComponent(Sprite) || node.getComponentInChildren(Sprite); + if (!sprite) return; + if (smc.uiconsAtlas) { + const frame = smc.uiconsAtlas.getSpriteFrame(iconId); + sprite.spriteFrame = frame || null; + } + } + + /** + * 根据卡牌类型和 UUID 解析出图标 ID(在 SpriteAtlas 中的帧名)。 + */ + private resolveCardIconId(type: CardType, uuid: number): string { + if (type === CardType.Skill) { + return SkillSet[uuid]?.icon || `${uuid}`; + } + if (type === CardType.Hero) { + return HeroInfo[uuid]?.icon || `${uuid}`; + } + return `${uuid}`; + } + + /** + * 为英雄卡图标加载并播放 idle 动画。 + * 使用 token 做竞态保护,确保异步回调时不会覆盖已更新的图标。 + */ + private updateHeroAnimation(node: Node, uuid: number, token: number) { + 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); + const path = `game/heros/hero/${hero.path}/idle`; + resources.load(path, AnimationClip, (err, clip) => { + if (err || !clip) { + mLogger.log(this.debugMode, "CardLiteComp", `load hero animation failed ${uuid}`, err); + return; + } + if (token !== this.iconVisualToken || !this.cardData || this.card_type !== CardType.Hero || this.card_uuid !== uuid) { + return; + } + this.clearAnimationClips(anim); + anim.addClip(clip); + anim.play("idle"); + }); + } + + /** 清除图标节点上的动画(停止播放并移除所有 clip) */ + private clearIconAnimation(node: Node) { + const anim = node?.getComponent(Animation); + if (!anim) return; + anim.stop(); + this.clearAnimationClips(anim); + } + + /** 移除 Animation 组件上的全部 AnimationClip */ + private clearAnimationClips(anim: Animation) { + const clips = (anim as any).clips as AnimationClip[] | undefined; + if (!clips || clips.length === 0) return; + [...clips].forEach(clip => anim.removeClip(clip, true)); + } + + // ======================== 生命周期钩子 ======================== /** ECS 组件移除时的释放钩子:销毁节点 */ reset() { diff --git a/assets/script/game/map/HerosListComp.ts b/assets/script/game/map/HerosListComp.ts index 9dcb7e9b..4e826cbb 100644 --- a/assets/script/game/map/HerosListComp.ts +++ b/assets/script/game/map/HerosListComp.ts @@ -2,83 +2,275 @@ * @file HerosListComp.ts * @description 英雄图鉴弹出页面(UI 视图层) * + * 职责: + * 1. 以卡片列表形式展示所有可用英雄(使用 CardLiteComp 预制体)。 + * 2. 点击卡片选中英雄,右侧详情面板显示 idle 动画、名称、AP、HP、CD、技能描述。 + * 3. 支持卡池等级筛选(全部 / Lv1 / Lv2 / Lv3)。 + * + * 关键设计: + * - cards_node 为卡片容器,通过 instantiate(card_lite_prefab) 动态生成卡片。 + * - 选中状态通过 selectNode 高亮管理,同一时间只有一张卡片高亮。 + * - hero_icon 使用 Animation + iconVisualToken 机制防止异步加载竞态。 + * + * 依赖: + * - HeroInfo / HeroList(heroSet)—— 英雄静态配置与全量 UUID 列表 + * - CardLiteComp —— 轻量卡片组件 + * - buildSkillDesc(HeroSkillDesc)—— 技能描述生成器 */ -import { _decorator, Animation, AnimationClip, Button, Event, Label, Node, NodeEventType, Sprite, resources, tween, Vec3, Widget, Prefab } from "cc"; +import { _decorator, Animation, AnimationClip, Label, Node, Prefab, Sprite, Widget, instantiate, resources } 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"; import { UIID } from "../common/config/GameUIConfig"; +import { HeroInfo, HeroList } from "../common/config/heroSet"; +import { buildSkillDesc } from "../common/config/HeroSkillDesc"; +import { CardLiteComp } from "./CardLiteComp"; -const {property, ccclass } = _decorator; +const { property, ccclass } = _decorator; -/** - * HerosListComp —— 英雄图鉴轮播视图组件 - * - * 在任务主页展示所有可用英雄,玩家可点击切换当前选中英雄的名称、AP、HP、CD、技能信息 - */ @ccclass('HerosListComp') @ecs.register('HerosListComp', false) export class HerosListComp extends CCComp { // ======================== 编辑器绑定节点 ======================== - /** 当前英雄 idle 图标节点 */ @property(Node) - hero_icon=null! - - /** 攻击力标签节点 */ - @property(Node) - ap_node=null! - /** 生命值标签节点 */ - @property(Node) - hp_node=null! - /** 冷却时间标签节点 */ - @property(Node) - cd_node=null! - /** 技能信息容器节点(包含 Line1~Line5 子节点) */ - @property(Node) - info_node=null! - /** 英雄名称标签节点 */ - @property(Node) - name_node=null! + hero_icon = null! - /** 英雄图鉴卡容器节点 */ @property(Node) - cards_node=null! + ap_node = null! + + @property(Node) + hp_node = null! + + @property(Node) + cd_node = null! + + @property(Node) + info_node = null! + + @property(Node) + name_node = null! + + @property(Node) + cards_node = null! - /** 英雄图鉴卡预制体 */ @property(Prefab) - card_lite_prefab=null! + card_lite_prefab = null! + @property(Node) + lv_node = null! + + @property(Node) + type_node = null! // ======================== 运行时状态 ======================== - /** 当前选中英雄在 HeroList 中的索引 */ - huuid:number=null! - /** 当前选中英雄在 HeroList 数组中的下标 */ - /** 调试日志开关 */ - debugMode: boolean = false; + huuid: number = 0 + private iconVisualToken: number = 0 + private selectNode: Node | null = null + debugMode: boolean = false - onLoad() { - - } - /** 预留:弹窗打开时接收参数 */ onAdded(args: any) { - + } - /** 关闭英雄图鉴弹窗 */ - closeHeros(){ + + start() { + this.initCardList() + if (HeroList.length > 0) { + this.onCardSelect(HeroList[0]) + } + } + + closeHeros() { oops.gui.remove(UIID.Heros) } - start() { - + // ======================== 卡片列表 ======================== + + private initCardList() { + mLogger.log(true, "HerosListComp", "initCardList start", { + hasCardsNode: !!this.cards_node, + hasPrefab: !!this.card_lite_prefab, + heroListLen: HeroList.length, + }) + + if (!this.cards_node || !this.card_lite_prefab) return + + mLogger.log(true, "HerosListComp", "cards_node info", { + name: this.cards_node.name, + active: this.cards_node.active, + childrenCount: this.cards_node.children.length, + pos: this.cards_node.position.toString(), + scale: this.cards_node.scale.toString(), + parentName: this.cards_node.parent?.name || "none", + }) + + this.cards_node.removeAllChildren() + + for (const uuid of HeroList) { + const hero = HeroInfo[uuid] + if (!hero) continue + + const cardNode = instantiate(this.card_lite_prefab) + cardNode.name = `card_${uuid}` + + mLogger.log(true, "HerosListComp", `card instantiated ${uuid}`, { + cardActive: cardNode.active, + cardPos: cardNode.position.toString(), + cardScale: cardNode.scale.toString(), + cardChildren: cardNode.children.map(c => c.name), + cardWidth: cardNode.getComponent("cc.UITransform")?.width, + cardHeight: cardNode.getComponent("cc.UITransform")?.height, + }) + + const comp = cardNode.getComponent(CardLiteComp) || cardNode.addComponent(CardLiteComp) + comp.setHeroByUuid(uuid, hero.pool_lv ?? 1) + comp.onClickCallback = (cardComp: CardLiteComp) => { + this.onCardSelect(uuid) + this.highlightCard(cardNode) + } + + this.cards_node.addChild(cardNode) + } + + mLogger.log(true, "HerosListComp", "initCardList done", { + totalChildren: this.cards_node.children.length, + }) + } + + private highlightCard(cardNode: Node) { + if (this.selectNode) { + const oldWidget = this.selectNode.getComponent(Widget) + if (oldWidget) oldWidget.enabled = false + this.selectNode.setScale(1, 1, 1) + } + this.selectNode = cardNode + cardNode.setScale(1.1, 1.1, 1) + } + + private onCardSelect(uuid: number) { + this.huuid = uuid + this.updateHeroDetail(uuid) + } + + // ======================== 详情面板 ======================== + + private updateHeroDetail(uuid: number) { + const hero = HeroInfo[uuid] + if (!hero) return + + const heroLv = Math.max(1, Math.floor(hero.lv ?? 1)) + const suffix = heroLv >= 2 ? "★".repeat(heroLv - 1) : "" + this.setLabelText(this.name_node, `${suffix}${hero.name || ""}${suffix}`) + this.setLabelText(this.ap_node, `${(hero.ap ?? 0) * heroLv}`) + this.setLabelText(this.hp_node, `${(hero.hp ?? 0) * heroLv}`) + + this.updateCdDisplay(hero) + + if (this.info_node) { + const desc = buildSkillDesc(hero) + const infoLabel = this.info_node.getChildByName("info")?.getComponent(Label) + || this.info_node.getComponent(Label) + || this.info_node.getComponentInChildren(Label) + if (infoLabel) infoLabel.string = desc || hero.info || "" + } + + this.updateLvDisplay(hero) + this.updateTypeDisplay(hero) + this.updateHeroAnimation(uuid) + } + + private updateCdDisplay(hero: typeof HeroInfo[number]) { + if (!this.cd_node) return + const skillKeys = Object.keys(hero.skills) + if (skillKeys.length === 0) return + const firstSkill = hero.skills[Number(skillKeys[0])] + if (firstSkill) { + this.setLabelText(this.cd_node, `${firstSkill.cd}s`) + } + } + + private updateLvDisplay(hero: typeof HeroInfo[number]) { + if (!this.lv_node) return + const cardLvStr = `lv${hero.pool_lv ?? 1}` + this.lv_node.active = true + this.lv_node.children.forEach(child => { + if (child.name === "light") { + child.active = false + } else if (child.name === "bg") { + child.active = true + } else { + child.active = (child.name === cardLvStr) + } + }) + const widget = this.lv_node.getComponent(Widget) + if (widget) widget.updateAlignment() + this.lv_node.children.forEach(child => { + const childWidget = child.getComponent(Widget) + if (childWidget) childWidget.updateAlignment() + }) + } + + private updateTypeDisplay(hero: typeof HeroInfo[number]) { + if (!this.type_node) return + this.type_node.active = true + const typeStr = `${hero.type ?? 0}` + this.type_node.children.forEach(child => { + child.active = (child.name === typeStr) + }) + } + + // ======================== 英雄动画 ======================== + + private updateHeroAnimation(uuid: number) { + if (!this.hero_icon) return + const hero = HeroInfo[uuid] + if (!hero) return + + const sprite = this.hero_icon.getComponent(Sprite) || this.hero_icon.getComponentInChildren(Sprite) + if (sprite) sprite.spriteFrame = null + + const anim = this.hero_icon.getComponent(Animation) || this.hero_icon.addComponent(Animation) + this.clearAnimationClips(anim) + + this.iconVisualToken += 1 + const token = this.iconVisualToken + const path = `game/heros/hero/${hero.path}/idle` + + resources.load(path, AnimationClip, (err, clip) => { + if (err || !clip) { + mLogger.log(this.debugMode, "HerosListComp", `load hero animation failed ${uuid}`, err) + return + } + if (token !== this.iconVisualToken) return + this.clearAnimationClips(anim) + anim.addClip(clip) + anim.play("idle") + }) + } + + 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) + } + } + } + + // ======================== UI 工具 ======================== + + private setLabelText(node: Node, text: string) { + if (!node) return + const label = node.getComponent(Label) || node.getComponentInChildren(Label) + if (label) { + label.string = text + } } - /** ECS 组件移除时销毁节点 */ reset() { - this.node.destroy(); + this.node.destroy() } }