Files
pixelheros/assets/script/game/map/CHeroComp.ts
panFD d135aa9905 refactor(heroCard): 重构英雄卡牌系统逻辑与UI布局
1.  重写抽卡逻辑,移除升级卡机制,改为从基础英雄池按权重抽取不重复英雄
2.  优化英雄卡入场、购买、刷新动画,新增按钮触摸反馈效果
3.  调整mission.prefab与cHero.prefab的UI元素位置与布局参数
4.  简化卡牌布局代码,交由prefab的Widget组件自动约束
2026-07-26 14:44:43 +08:00

597 lines
22 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 CHeroComp.ts
* @description 英雄卡池单个卡槽组件UI 视图层 + 购买/召唤逻辑)
*
* 职责:
* 1. 渲染单个英雄卡的显示信息(名称、等级、描述、图标、费用),
* 展示方式与 SCardComp 一致(图集静态图标 + ★等级 + 描述词条)。
* 同时支持动态升级卡SpecialUpgrade + target_hero_eid按英雄卡样式渲染。
* 2. 处理购买点击 → UseHeroCard 上限校验(guard) → 扣费 → 派发 CallHero 召唤英雄。
* 特殊卡(升级/刷新)则派发 UseSpecialCard。
* 3. 使用成功后派发 CardUsed 事件,通知 MissionCardComp 刷新英雄卡池。
*
* 与 SCardComp 的差异:
* - 数据源为 HeroInfoheroSet而非 SkillCardList / SkillSet。
* - 购买前需通过 GameEvent.UseHeroCard 做英雄数量上限校验。
* - 英雄卡效果事件为 GameEvent.CallHero而非 UseSkillCard。
*/
import { mLogger } from "../common/Logger";
import { _decorator, Node, Sprite, Label, NodeEventType, Vec3, Tween, tween, UIOpacity } 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 } from "../common/config/CardSet";
import { HeroInfo } from "../common/config/heroSet";
import { oops } from "db://oops-framework/core/Oops";
import { GameEvent } from "../common/config/GameEvent";
import { smc } from "../common/SingletonModuleComp";
import { HeroAttrsComp } from "../hero/HeroAttrsComp";
import { FieldSkillType } from "../common/config/SkillSet";
import { FieldSkillHelper } from "../hero/FieldSkillHelper";
import { MissionEconomy } from "./MissionEconomy";
const { ccclass, property } = _decorator;
/**
* CHeroComp —— 英雄卡池单个英雄卡项
*
* 由 MissionCardComp.cacheCardComps() 实例化。
* 以图标 + 名称 + 描述词条 + 购买按钮的形式呈现。
*/
@ccclass('CHeroComp')
@ecs.register('CHeroComp', false)
export class CHeroComp extends CCComp {
private debugMode: boolean = true;
// ======================== 编辑器绑定节点 ========================
/** 英雄图标节点 */
@property({ type: Node })
private icon_node: Node = null;
/** 背景节点 */
@property({ type: Node })
private bg_node: Node = null;
/** 英雄名称 */
@property(Label)
private name_label: Label = null;
/** 描述词条 1 */
@property(Label)
private fied1: Label = null;
/** 描述词条 2 */
@property(Label)
private fied2: Label = null;
/** 描述词条 3 */
@property(Label)
private fied3: Label = null;
/** 等级标签 */
@property(Label)
private level_label: Label = null;
/** 购买按钮节点 */
@property({ type: Node })
private buy_node: Node = null;
// ======================== 运行时状态 ========================
/** 当前槽位承载的卡牌配置null 表示空槽 */
private cardData: CardConfig | null = null;
/** 是否已购买 */
private isPurchased: boolean = false;
/** 是否已缓存基准 Y/Z 坐标(首次 setSlotPosition 时确定) */
private hasFixedBasePosition: boolean = false;
/** 槽位基准 Y 坐标 */
private fixedBaseY: number = 0;
/** 槽位基准 Z 坐标 */
private fixedBaseZ: number = 0;
/** 静止位置(由 MissionCardComp 布局决定) */
private restPosition: Vec3 = new Vec3();
/** 透明度组件(用于淡入淡出) */
private opacityComp: UIOpacity | null = null;
// ======================== 生命周期 ========================
onLoad() {
mLogger.log(this.debugMode, "CHeroComp", "onLoad bindings", {
nodeName: this.node?.name,
nodeUuid: this.node?.uuid,
hasCardData: !!this.cardData,
cardUuid: this.cardData?.uuid ?? null,
icon_node: !!this.icon_node,
bg_node: !!this.bg_node,
name_label: !!this.name_label,
level_label: !!this.level_label,
fied1: !!this.fied1,
fied2: !!this.fied2,
fied3: !!this.fied3,
buy_node: !!this.buy_node
});
this.buy_node?.on(NodeEventType.TOUCH_START, this.onBuyTouchStart, this);
this.buy_node?.on(NodeEventType.TOUCH_END, this.onBuyClick, this);
this.buy_node?.on(NodeEventType.TOUCH_CANCEL, this.onBuyTouchCancel, this);
// 缓存透明度组件(用于淡入淡出动画)
this.opacityComp = this.node.getComponent(UIOpacity) || this.node.addComponent(UIOpacity);
// 补偿首次渲染addComponent 当帧 onLoad 尚未触发,
// 若 MissionCardComp 在 onLoad 前已 applyCardData此处绑定就绪后重刷一次
if (this.cardData) {
this.updateUI();
}
}
onDestroy() {
super.onDestroy();
if (this.buy_node && this.buy_node.isValid) {
this.buy_node.off(NodeEventType.TOUCH_START, this.onBuyTouchStart, this);
this.buy_node.off(NodeEventType.TOUCH_END, this.onBuyClick, this);
this.buy_node.off(NodeEventType.TOUCH_CANCEL, this.onBuyTouchCancel, this);
}
}
// ======================== 数据初始化 ========================
/**
* 初始化英雄卡数据并渲染 UI
*
* 若槽位已有旧卡且处于显示状态,先播放旧卡"逝去"动画(缩小淡出),
* 动画结束后再切换为新卡数据并播放入场动画。
* 首次赋值node 未激活)直接渲染,无逝去动画。
*
* @param data 卡牌配置,传 null 时清空显示
*/
applyCardData(data: CardConfig | null): void {
mLogger.log(this.debugMode, "CHeroComp", "applyCardData", {
nodeName: this.node?.name,
nodeUuid: this.node?.uuid,
uuid: data?.uuid ?? null,
type: data?.type ?? null,
name: data?.name ?? null,
card_lv: data?.card_lv ?? null,
hero_lv: data?.hero_lv ?? null,
cost: data?.cost ?? null,
icon: data?.icon ?? null,
nodeActiveBefore: this.node?.active
});
// 刚购买(已播放大逝去动画)→ 不播普通逝去动画,直接应用新卡
if (this.isPurchased) {
this.cardData = data;
this.isPurchased = false;
if (data) {
this.node.active = true;
this.updateUI();
} else {
this.applyEmptyUI();
}
return;
}
// 旧卡存在且当前显示中 → 播逝去动画,结束后再应用新卡
if (this.cardData && this.node.active) {
this.playRefreshExitAnim(() => {
this.cardData = data;
this.isPurchased = false;
if (data) {
this.node.active = true;
this.updateUI();
} else {
this.applyEmptyUI();
}
});
return;
}
// 首次赋值或槽位为空 → 直接渲染
this.cardData = data;
this.isPurchased = false;
if (data) {
this.node.active = true;
this.updateUI();
} else {
this.applyEmptyUI();
}
}
/** 清空 UI 显示 */
private applyEmptyUI() {
Tween.stopAllByTarget(this.node);
if (this.opacityComp) {
Tween.stopAllByTarget(this.opacityComp);
this.opacityComp.opacity = 255;
}
this.node.setScale(1, 1, 1);
if (this.name_label) this.name_label.string = "";
if (this.level_label) this.level_label.string = "";
const fieldLabels = [this.fied1, this.fied2, this.fied3];
for (const label of fieldLabels) {
if (label) {
label.string = "";
label.node.active = false;
}
}
if (this.icon_node) {
const sprite = this.icon_node.getComponent(Sprite) || this.icon_node.getComponentInChildren(Sprite);
if (sprite) sprite.spriteFrame = null;
}
if (this.buy_node) {
this.buy_node.active = false;
}
this.node.active = false;
}
// ======================== UI 渲染 ========================
updateUI() {
if (!this.cardData) {
mLogger.log(this.debugMode, "CHeroComp", "updateUI skip: no cardData", { nodeName: this.node?.name });
return;
}
// 动态升级卡SpecialUpgrade + target_hero_eid按英雄卡样式渲染
// 通过 eid 反查场上实体得到 hero_uuid实体不存在时降级为本卡 uuid
const isUpgradeCard = this.cardData.type === CardType.SpecialUpgrade && !!this.cardData.target_hero_eid;
const renderUuid = isUpgradeCard
? (this.queryHeroUuidByEid(this.cardData.target_hero_eid as number) ?? this.cardData.uuid)
: this.cardData.uuid;
const hero = HeroInfo[renderUuid];
mLogger.log(this.debugMode, "CHeroComp", "updateUI resolve", {
nodeName: this.node?.name,
uuid: this.cardData.uuid,
type: this.cardData.type,
isUpgradeCard,
renderUuid,
heroFound: !!hero,
heroName: hero?.name ?? null,
heroPath: hero?.path ?? null,
heroInfo: hero?.info ?? null
});
// 名称
if (this.name_label) {
const finalName = hero?.name || this.cardData.name || "";
this.name_label.string = finalName;
mLogger.log(this.debugMode, "CHeroComp", "name_label assigned", {
nodeName: this.node?.name,
finalName,
labelStringAfter: this.name_label.string,
labelNodeActive: this.name_label.node?.active,
labelNodeParentActive: this.name_label.node?.parent?.active,
labelNodeParentName: this.name_label.node?.parent?.name
});
}
// 等级标签(★ 表示卡牌等级;升级卡显示英雄目标等级)
if (this.level_label) {
if (isUpgradeCard) {
const heroLv = Math.max(1, this.cardData.hero_lv ?? 1);
this.level_label.string = heroLv > 1 ? `Lv.${heroLv - 1} -> Lv.${heroLv}` : `Lv.${heroLv}`;
} else {
const lv = this.cardData.card_lv ?? 1;
this.level_label.string = lv >= 2 ? "★".repeat(lv - 1) : "";
}
}
// 描述词条(最多显示 3 条,对应 fied1/fied2/fied3
const fieldLabels = [this.fied1, this.fied2, this.fied3];
const desc = hero?.info || this.cardData.info || "";
for (let i = 0; i < fieldLabels.length; i++) {
if (!fieldLabels[i]) continue;
fieldLabels[i].string = i === 0 ? desc : "";
fieldLabels[i].node.active = i === 0 && !!desc;
}
// 图标
if (this.icon_node) {
const iconId = this.getIconId(renderUuid);
if (iconId) {
this.updateIcon(this.icon_node, iconId);
}
}
// 购买按钮费用显示
if (this.buy_node) {
this.buy_node.active = true;
const costLabel = this.buy_node.getChildByName("num")?.getComponent(Label)
|| this.buy_node.getComponentInChildren(Label);
if (costLabel) {
costLabel.string = `${this.cardData.cost ?? 0}`;
}
}
// 入场动画:从静止位置下方升起 + 淡入
this.playEnterAnim();
}
// ======================== 动画效果 ========================
/** 入场动画时长(秒) */
private readonly enterDuration: number = 0.25;
/** 购买动画时长(秒) */
private readonly purchaseDuration: number = 0.2;
/**
* 播放入场动画由小变大scale 0→1+ 淡入。
* 不依赖位置,与 Widget 布局完全解耦。
*/
private playEnterAnim() {
Tween.stopAllByTarget(this.node);
if (this.opacityComp) {
Tween.stopAllByTarget(this.opacityComp);
this.opacityComp.opacity = 0;
}
this.node.setScale(0, 0, 1);
tween(this.node)
.to(this.enterDuration, { scale: new Vec3(1, 1, 1) }, { easing: 'backOut' })
.start();
if (this.opacityComp) {
tween(this.opacityComp)
.to(this.enterDuration, { opacity: 255 })
.start();
}
}
/**
* 播放购买动画卡牌放大scale 1→1.1+ 淡出,结束后隐藏节点。
* 动画结束由 onBuyClick 的后续逻辑CardUsed 刷新卡池)接管。
*/
private playPurchaseAnim() {
Tween.stopAllByTarget(this.node);
if (this.opacityComp) {
Tween.stopAllByTarget(this.opacityComp);
}
tween(this.node)
.to(this.purchaseDuration, { scale: new Vec3(1.1, 1.1, 1) }, { easing: 'quadIn' })
.start();
if (this.opacityComp) {
tween(this.opacityComp)
.to(this.purchaseDuration, { opacity: 0 })
.start();
}
}
/**
* 播放普通刷新"逝去"动画旧卡缩小scale 1→0+ 淡出,结束后回调切换新卡。
* 与购买动画区分:购买是放大逝去,普通刷新是缩小逝去。
* @param onComplete 动画结束回调,用于应用新卡数据
*/
private playRefreshExitAnim(onComplete: () => void) {
Tween.stopAllByTarget(this.node);
if (this.opacityComp) {
Tween.stopAllByTarget(this.opacityComp);
}
tween(this.node)
.to(this.purchaseDuration, { scale: new Vec3(0, 0, 1) }, { easing: 'quadIn' })
.start();
if (this.opacityComp) {
tween(this.opacityComp)
.to(this.purchaseDuration, { opacity: 0 })
.call(onComplete)
.start();
} else {
// 无透明度组件时直接延迟回调
this.scheduleOnce(onComplete, this.purchaseDuration);
}
}
// ======================== 按钮动画 ========================
/** 按钮正常缩放 */
private readonly btnNormalScale: number = 1;
/** 按钮按下缩放 */
private readonly btnPressScale: number = 0.92;
/** 按钮点击回弹峰值缩放 */
private readonly btnClickScale: number = 1.08;
/** 购买按钮按下:缩小反馈 */
private onBuyTouchStart() {
this.playBtnScaleTo(this.btnPressScale, 0.06);
}
/** 购买按钮取消:恢复缩放 */
private onBuyTouchCancel() {
this.playBtnScaleTo(this.btnNormalScale, 0.08);
}
/** 按钮缩放动画(通用) */
private playBtnScaleTo(scale: number, duration: number) {
if (!this.buy_node || !this.buy_node.isValid) return;
Tween.stopAllByTarget(this.buy_node);
tween(this.buy_node)
.to(duration, { scale: new Vec3(scale, scale, 1) })
.start();
}
/** 按钮点击回弹动画:先弹到峰值再回到正常 */
private playBtnClickAnim(onComplete?: () => void) {
if (!this.buy_node || !this.buy_node.isValid) {
onComplete?.();
return;
}
Tween.stopAllByTarget(this.buy_node);
tween(this.buy_node)
.to(0.05, { scale: new Vec3(this.btnClickScale, this.btnClickScale, 1) })
.to(0.08, { scale: new Vec3(this.btnNormalScale, this.btnNormalScale, 1) })
.call(() => onComplete?.())
.start();
}
/**
* 通过 eid 反查场上英雄实体的 hero_uuid。
* 用于动态升级卡渲染:升级卡绑定的是 eid但显示需要 HeroInfo[uuid]。
* @returns 找到则返回 hero_uuid找不到实体已销毁等返回 null
*/
private queryHeroUuidByEid(eid: number): number | null {
if (!eid) return null;
const entity = ecs.getEntityByEid(eid);
if (!entity) return null;
const model = entity.get(HeroAttrsComp);
if (!model) return null;
return model.hero_uuid ?? null;
}
/** 获取英雄图标 id优先卡牌自定义 icon其次英雄 path兜底 uuid */
private getIconId(renderUuid: number): string {
if (!this.cardData) return "";
if (this.cardData.icon) return this.cardData.icon;
const hero = HeroInfo[renderUuid];
return hero?.path || `${renderUuid}`;
}
/** 更新图标精灵 */
private updateIcon(node: Node, iconId: string) {
if (!node || !iconId) return;
const sprite = node.getComponent(Sprite) || node.getComponentInChildren(Sprite);
const frame = smc.uiconsAtlas ? smc.uiconsAtlas.getSpriteFrame(iconId) : null;
mLogger.log(this.debugMode, "CHeroComp", "updateIcon", {
nodeName: this.node?.name,
iconId,
spriteFound: !!sprite,
atlasReady: !!smc.uiconsAtlas,
frameFound: !!frame
});
if (!sprite) return;
if (smc.uiconsAtlas) {
sprite.spriteFrame = frame || null;
}
}
// ======================== 购买逻辑 ========================
/**
* 购买点击处理:
* 1. 播放按钮点击回弹动画。
* 2. 英雄卡先通过 UseHeroCard 事件做数量上限校验guard 模式,可被取消)。
* 3. 扣除金币(失败则提示并中止)。
* 4. 按卡牌类型分发效果:英雄卡 → CallHero 召唤英雄;特殊卡 → UseSpecialCard。
* 5. 派发 CardUsed 通知 MissionCardComp 刷新英雄卡池。
* 6. 标记已购买,隐藏购买按钮。
*/
private onBuyClick() {
if (!this.cardData || this.isPurchased) return;
// 播放按钮点击回弹动画(视觉反馈,不阻塞后续逻辑)
this.playBtnClickAnim();
oops.audio.playEffect("music/button");
// 英雄卡数量上限校验guard 模式MissionCardComp 可设 cancel=true 阻止使用
if (this.cardData.type === CardType.Hero) {
const guard = {
cancel: false,
reason: "",
uuid: this.cardData.uuid,
hero_lv: this.cardData.hero_lv ?? 1,
card_lv: this.cardData.base_pool_lv ?? this.cardData.pool_lv ?? 1
};
oops.message.dispatchEvent(GameEvent.UseHeroCard, guard);
if (guard.cancel) return;
}
// 扣费(英雄卡享受驻场"购买优惠"折扣)
let cost = this.cardData.cost ?? 0;
if (this.cardData.type === CardType.Hero) {
const discount = FieldSkillHelper.getFieldSkillTotalValue(FieldSkillType.BuyDiscount);
cost = Math.max(0, cost - discount);
}
const success = MissionEconomy.spendCoin(Math.floor(cost));
if (!success) {
oops.message.dispatchEvent(GameEvent.ShowSmallTip, "buy_coin");
mLogger.log(this.debugMode, "CHeroComp", "purchase failed: not enough coin", {
uuid: this.cardData.uuid,
cost
});
return;
}
smc.vmdata.scores.refresh_hit_count++;
const used = this.cardData;
// 按卡牌类型分发效果事件
switch (used.type) {
case CardType.Hero:
oops.message.dispatchEvent(GameEvent.CallHero, used);
break;
case CardType.SpecialUpgrade:
case CardType.SpecialRefresh:
oops.message.dispatchEvent(GameEvent.UseSpecialCard, used);
break;
}
// 播放购买动画(缩小淡出),刷新卡池由 CardUsed 事件触发
this.playPurchaseAnim();
// 标记已购买
this.isPurchased = true;
if (this.buy_node) {
this.buy_node.active = false;
}
// 通知 MissionCardComp本槽位卡牌已使用刷新英雄卡池
oops.message.dispatchEvent(GameEvent.CardUsed, this);
mLogger.log(this.debugMode, "CHeroComp", "hero card purchased", {
uuid: used.uuid,
type: used.type,
cost
});
}
/** 标记为已购买状态 */
setPurchased(): void {
this.isPurchased = true;
if (this.buy_node) {
this.buy_node.active = false;
}
}
/**
* 接收 MissionCardComp 分发的卡牌并刷新显示。
* @param data 卡牌配置null 时清空显示
* @returns 是否成功接收true = 接收并刷新 UI
*/
applyDrawCard(data: CardConfig | null): boolean {
this.applyCardData(data);
return !!data;
}
/**
* 设置槽位的水平位置(由 MissionCardComp 布局计算后调用)。
* 首次调用时缓存基准 Y/Z后续仅更新 X。
* @param x 目标水平坐标
*/
setSlotPosition(x: number) {
const current = this.node.position;
if (!this.hasFixedBasePosition) {
this.fixedBaseY = current.y;
this.fixedBaseZ = current.z;
this.hasFixedBasePosition = true;
}
this.restPosition = new Vec3(x, this.fixedBaseY, this.fixedBaseZ);
this.node.setPosition(this.restPosition);
}
/**
* 系统清槽:任务开始/结束时强制重置。
* 清空数据与 UI 并隐藏节点。
*/
clearBySystem() {
this.cardData = null;
this.isPurchased = false;
this.node.setPosition(this.restPosition);
this.applyEmptyUI();
this.node.active = false;
}
/** ECS 组件移除时销毁节点 */
reset() {
if (this.node && this.node.isValid) {
this.node.destroy();
}
}
}