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

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

926 lines
35 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 MissionCardComp.ts
* @description 卡牌系统核心控制器(战斗 UI 层 + 业务逻辑层)
*
* 职责:
* 1. **卡牌分发管理** —— 从卡池抽取 4 张卡,分发到 4 个 CardComp 槽位。
* 2. **卡池升级** —— 消耗金币提升卡池等级poolLv解锁更高稀有度的卡牌。
* 3. **金币费用管理** —— 抽卡费用refreshCost、升级费用CardsUpSet
* 波次折扣CARD_POOL_UPGRADE_DISCOUNT_PER_WAVE的计算与扣除。
* 4. **英雄数量上限校验** —— 在 UseHeroCard 事件的 guard 阶段判断是否允许
* 再召唤英雄(含合成后腾位的特殊判断 canUseHeroCardByMerge
* 5. **场上英雄信息面板HInfoComp 列表)同步** ——
* 英雄上场时实例化面板,死亡时移除,定时刷新属性显示。
* 6. **特殊卡执行** —— 处理英雄升级卡SpecialUpgrade和英雄刷新卡SpecialRefresh
* 7. **准备/战斗阶段切换** —— 控制卡牌面板的 展开/收起 动画。
*
* 关键设计:
* - 4 个 CardComp 通过 cacheCardComps() 映射为有序数组 cardComps[]
* 之后所有分发、清空操作均通过此数组进行。
* - buildDrawCards() 保证每次抽取 4 张,不足时循环补齐。
* - 英雄上限校验onUseHeroCard采用 guard/cancel 模式:
* CardComp 发出 UseHeroCard 事件并传入 guard 对象,
* 本组件可通过 guard.cancel=true 阻止使用。
* - ensureHeroInfoPanel() 建立 EID → HInfoComp 的 Map 映射,
* 支持英雄合成升级后面板热更新。
*
* 依赖:
* - CardComp —— 单卡槽位
* - HInfoComp —— 英雄信息面板
* - CardSet 模块 —— 卡池配置、抽卡规则、特殊卡数据
* - HeroAttrsComp —— 英雄属性(合成校验 / 升级)
* - MissionHeroCompComp —— 获取合成规则needCount / maxLv
* - smc.vmdata.mission_data —— 局内数据coin / hero_num / hero_max_num
*/
import { mLogger } from "../common/Logger";
import { _decorator, instantiate, Label, Node, NodeEventType, Prefab, SpriteAtlas, Tween, 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 { GameEvent } from "../common/config/GameEvent";
import { CARD_POOL_INIT_LEVEL, CARD_POOL_MAX_LEVEL, CARD_POOL_UPGRADE_DISCOUNT_PER_WAVE, CardConfig, CardType, CardsUpSet, drawCardsByRule, getCardsByLv, SpecialRefreshCardList, SpecialRefreshHeroType, SpecialUpgradeCardList } from "../common/config/CardSet";
import { CardComp } from "./CardComp";
import { oops } from "db://oops-framework/core/Oops";
import { HeroAttrsComp } from "../hero/HeroAttrsComp";
import { HInfoComp } from "./HInfoComp";
import { smc } from "../common/SingletonModuleComp";
import { HeroInfo, HType } from "../common/config/heroSet";
import { HeroViewComp } from "../hero/HeroViewComp";
import { FacSet } from "../common/config/GameSet";
import { MoveComp } from "../hero/MoveComp";
import { MissionHeroCompComp } from "./MissionHeroComp";
const { ccclass, property } = _decorator;
/**
* MissionCardComp —— 卡牌系统核心控制器
*
* 管理 4 个卡牌槽位的抽卡分发、卡池升级、金币费用、
* 英雄上限校验、场上英雄信息面板同步以及特殊卡执行。
*/
@ccclass('MissionCardComp')
@ecs.register('MissionCard', false)
export class MissionCardComp extends CCComp {
private debugMode: boolean = true;
private readonly cardWidth: number = 175;
private readonly buttonNormalScale: number = 1;
private readonly buttonPressScale: number = 0.94;
private readonly buttonClickScale: number = 1.06;
refreshCost: number = 1;
cardsPanelMoveDuration: number = 0.2;
/** 四个插卡槽位固定顺序分发1~4 */
@property(Node)
cards_node:Node = null!
@property(Node)
card1:Node = null!
@property(Node)
card2:Node = null!
@property(Node)
card3:Node = null!
@property(Node)
card4:Node = null!
@property(Node)
cards_chou:Node = null!
@property(Node)
cards_up:Node = null!
@property(Node)
coins_node:Node = null!
@property(Node)
pool_lv_node:Node = null!
@property(Node)
hero_info_node:Node = null! //场上英雄信息面板所在节点
@property(Prefab)
hero_info_prefab:Prefab=null! //场上英雄信息面板Prefab
@property(Node)
hero_num_node:Node=null!
/** 预留图集缓存(后续接入按钮/卡面图标时复用) */
private uiconsAtlas: SpriteAtlas | null = null;
/** 四个槽位对应的单卡控制器缓存 */
private cardComps: CardComp[] = [];
/** 当前卡池等级(仅影响抽卡来源,不直接改卡槽现有内容) */
private poolLv: number = CARD_POOL_INIT_LEVEL;
private readonly heroInfoItemGap: number = 110;
private readonly heroInfoItemSpacing: number = 5;
private heroInfoSyncTimer: number = 0;
private hasCachedCardsBaseScale: boolean = false;
private cardsBaseScale: Vec3 = new Vec3(1, 1, 1);
private cardsShowScale: Vec3 = new Vec3(1, 1, 1);
private cardsHideScale: Vec3 = new Vec3(0, 0, 1);
private heroInfoItems: Map<number, {
node: Node,
model: HeroAttrsComp,
comp: HInfoComp
}> = new Map();
onLoad() {
/** 绑定事件 -> 缓存子控制器 -> 初始化UI状态 */
this.bindEvents();
this.cacheCardComps();
this.layoutCardSlots();
this.initCardsPanelPos();
this.onMissionStart();
mLogger.log(this.debugMode, "MissionCardComp", "onLoad init", {
slots: this.cardComps.length,
poolLv: this.poolLv
});
}
onDestroy() {
this.unbindEvents();
this.clearHeroInfoPanels();
}
init(){
this.onMissionStart();
}
/** 任务开始时重置卡池等级、清空4槽、显示面板 刷新一次卡池*/
onMissionStart() {
this.enterPreparePhase();
this.poolLv = CARD_POOL_INIT_LEVEL;
const missionData = this.getMissionData();
if (missionData) {
missionData.coin = Math.max(0, Math.floor(missionData.coin ?? 0));
missionData.hero_num = 0;
missionData.hero_max_num = 5;
missionData.hero_extend_max_num = 6;
}
this.clearHeroInfoPanels();
this.layoutCardSlots();
this.clearAllCards();
if (this.cards_up) {
this.cards_up.active = true;
}
this.resetButtonScale(this.cards_chou);
this.resetButtonScale(this.cards_up);
this.updateCoinAndCostUI();
this.updateHeroNumUI(false, false);
this.node.active = true;
const cards = this.buildDrawCards();
this.dispatchCardsToSlots(cards);
mLogger.log(this.debugMode, "MissionCardComp", "mission start", {
poolLv: this.poolLv
});
}
/** 任务结束时清空4槽并隐藏面板 */
onMissionEnd() {
this.clearAllCards();
this.clearHeroInfoPanels();
this.node.active = false;
}
start() {
}
update(dt: number) {
this.heroInfoSyncTimer += dt;
if (this.heroInfoSyncTimer < 0.15) return;
this.heroInfoSyncTimer = 0;
this.refreshHeroInfoPanels();
}
/** 关闭面板(不销毁数据模型,仅隐藏) */
close() {
this.node.active = false;
}
/** 只处理UI层事件不做卡牌效果分发 */
private bindEvents() {
/** 生命周期事件 */
this.on(GameEvent.MissionStart, this.onMissionStart, this);
this.on(GameEvent.MissionEnd, this.onMissionEnd, this);
this.on(GameEvent.NewWave, this.onNewWave, this);
this.on(GameEvent.FightStart, this.onFightStart, this);
oops.message.on(GameEvent.CoinAdd, this.onCoinAdd, this);
oops.message.on(GameEvent.MasterCalled, this.onMasterCalled, this);
oops.message.on(GameEvent.HeroDead, this.onHeroDead, this);
oops.message.on(GameEvent.UseHeroCard, this.onUseHeroCard, this);
oops.message.on(GameEvent.UseSpecialCard, this.onUseSpecialCard, this);
/** 按钮事件:抽卡与卡池升级 */
this.cards_chou?.on(NodeEventType.TOUCH_START, this.onDrawTouchStart, this);
this.cards_chou?.on(NodeEventType.TOUCH_END, this.onDrawTouchEnd, this);
this.cards_chou?.on(NodeEventType.TOUCH_CANCEL, this.onDrawTouchCancel, this);
this.cards_up?.on(NodeEventType.TOUCH_START, this.onUpgradeTouchStart, this);
this.cards_up?.on(NodeEventType.TOUCH_END, this.onUpgradeTouchEnd, this);
this.cards_up?.on(NodeEventType.TOUCH_CANCEL, this.onUpgradeTouchCancel, this);
}
private onCoinAdd(event: string, args: any){
const payload = args ?? event;
if (payload?.syncOnly) {
this.updateCoinAndCostUI();
this.playCoinChangeAnim((payload?.delta ?? 0) > 0);
return;
}
const v = typeof payload === 'number' ? payload : (payload?.delta ?? payload?.value ?? 0);
if (v === 0) return;
this.setMissionCoin(this.getMissionCoin() + v);
this.updateCoinAndCostUI();
this.playCoinChangeAnim(v > 0);
}
private onFightStart() {
this.enterBattlePhase();
}
private onNewWave() {
this.enterPreparePhase();
this.updateCoinAndCostUI();
this.layoutCardSlots();
const cards = this.buildDrawCards();
this.dispatchCardsToSlots(cards);
}
/** 解除按钮监听,避免节点销毁后回调泄漏 */
private unbindEvents() {
oops.message.off(GameEvent.CoinAdd, this.onCoinAdd, this);
oops.message.off(GameEvent.MasterCalled, this.onMasterCalled, this);
oops.message.off(GameEvent.HeroDead, this.onHeroDead, this);
oops.message.off(GameEvent.UseHeroCard, this.onUseHeroCard, this);
oops.message.off(GameEvent.UseSpecialCard, this.onUseSpecialCard, this);
this.cards_chou?.off(NodeEventType.TOUCH_START, this.onDrawTouchStart, this);
this.cards_chou?.off(NodeEventType.TOUCH_END, this.onDrawTouchEnd, this);
this.cards_chou?.off(NodeEventType.TOUCH_CANCEL, this.onDrawTouchCancel, this);
this.cards_up?.off(NodeEventType.TOUCH_START, this.onUpgradeTouchStart, this);
this.cards_up?.off(NodeEventType.TOUCH_END, this.onUpgradeTouchEnd, this);
this.cards_up?.off(NodeEventType.TOUCH_CANCEL, this.onUpgradeTouchCancel, this);
}
private onMasterCalled(event: string, args: any) {
const payload = args ?? event;
const eid = Number(payload?.eid ?? 0);
const model = payload?.model as HeroAttrsComp | undefined;
if (!eid || !model) return;
const before = this.getAliveHeroCount();
this.ensureHeroInfoPanel(eid, model);
const after = this.getAliveHeroCount();
this.updateHeroNumUI(true, after > before);
}
private onHeroDead() {
this.refreshHeroInfoPanels();
this.updateHeroNumUI(true, false);
}
private onUseHeroCard(event: string, args: any) {
const payload = args ?? event;
if (!payload) return;
const current = this.getAliveHeroCount();
this.syncMissionHeroData(current);
const heroMax = this.getMissionHeroMaxNum();
if (current >= heroMax) {
const heroUuid = Number(payload?.uuid ?? 0);
const heroLv = Math.max(1, Math.floor(Number(payload?.hero_lv ?? 1)));
const cardLv = Math.max(1, Math.floor(Number(payload?.pool_lv ?? 1)));
if (this.canUseHeroCardByMerge(heroUuid, heroLv)) {
payload.cancel = false;
payload.reason = "";
return;
}
payload.cancel = true;
payload.reason = "hero_limit";
oops.gui.toast(`英雄已满 (${current}/${heroMax})`);
this.playHeroNumDeniedAnim();
}
}
private canUseHeroCardByMerge(heroUuid: number, heroLv: number): boolean {
if (!heroUuid) return false;
const mergeRule = this.getMergeRule();
if (heroLv >= mergeRule.maxLv) return false;
const sameCount = this.countAliveHeroesByUuidAndLv(heroUuid, heroLv);
return sameCount + 1 >= mergeRule.needCount;
}
private countAliveHeroesByUuidAndLv(heroUuid: number, heroLv: number): number {
let count = 0;
const actors = this.queryAliveHeroActors();
for (let i = 0; i < actors.length; i++) {
const model = actors[i].model;
if (!model) continue;
if (model.hero_uuid !== heroUuid) continue;
if (model.lv !== heroLv) continue;
count += 1;
}
return count;
}
private getMergeRule(): { needCount: number, maxLv: number } {
let needCount = 3;
let maxLv = 2; // 兜底值改为2与 MissionHeroComp 保持一致
ecs.query(ecs.allOf(MissionHeroCompComp)).forEach((entity: ecs.Entity) => {
const comp = entity.get(MissionHeroCompComp);
if (!comp) return;
needCount = comp.merge_need_count === 2 ? 2 : 3;
maxLv = Math.max(1, Math.floor(comp.merge_max_lv ?? 2));
});
return { needCount, maxLv };
}
private onUseSpecialCard(event: string, args: any) {
const payload = args ?? event;
const uuid = Number(payload?.uuid ?? 0);
const type = Number(payload?.type ?? 0) as CardType;
if (!uuid) return;
let success = false;
if (type === CardType.SpecialUpgrade) {
const card = SpecialUpgradeCardList[uuid];
if (!card) return;
success = this.tryUpgradeOneHero(card.currentLv, card.targetLv);
if (!success) oops.gui.toast(`场上没有可从${card.currentLv}级升到${card.targetLv}级的英雄`);
} else if (type === CardType.SpecialRefresh) {
const card = SpecialRefreshCardList[uuid];
if (!card) return;
success = this.tryRefreshHeroCardsByEffect(card.refreshHeroType, card.refreshLv);
if (!success) oops.gui.toast("当前卡池无符合条件的英雄卡");
}
mLogger.log(this.debugMode, "MissionCardComp", "use special card", {
uuid,
type,
success
});
}
private onDrawTouchStart() {
this.playButtonPressAnim(this.cards_chou);
}
private onDrawTouchEnd() {
this.playButtonClickAnim(this.cards_chou, () => this.onClickDraw());
}
private onDrawTouchCancel() {
this.playButtonResetAnim(this.cards_chou);
}
private onUpgradeTouchStart() {
this.playButtonPressAnim(this.cards_up);
}
private onUpgradeTouchEnd() {
this.playButtonClickAnim(this.cards_up, () => this.onClickUpgrade());
}
private onUpgradeTouchCancel() {
this.playButtonResetAnim(this.cards_up);
}
/** 将四个卡槽节点映射为 CardComp形成固定顺序控制数组 */
private cacheCardComps() {
const nodes = [this.card1, this.card2, this.card3, this.card4];
this.cardComps = nodes
.map(node => node?.getComponent(CardComp))
.filter((comp): comp is CardComp => !!comp);
}
/** 抽卡按钮每次固定抽4张然后顺序分发给4个单卡脚本 */
private onClickDraw() {
const cost = this.getRefreshCost();
const currentCoin = this.getMissionCoin();
if (currentCoin < cost) {
oops.gui.toast(`金币不足,刷新需要${cost}`);
this.updateCoinAndCostUI();
mLogger.log(this.debugMode, "MissionCardComp", "draw coin not enough", {
currentCoin,
cost
});
return;
}
this.setMissionCoin(currentCoin - cost);
this.playCoinChangeAnim(false);
this.updateCoinAndCostUI();
mLogger.log(this.debugMode, "MissionCardComp", "click draw", {
poolLv: this.poolLv,
cost,
leftCoin: this.getMissionCoin()
});
this.layoutCardSlots();
const cards = this.buildDrawCards();
this.dispatchCardsToSlots(cards);
}
/** 升级按钮:仅提升卡池等级,卡槽是否更新由下一次抽卡触发 */
private onClickUpgrade() {
if (this.poolLv >= CARD_POOL_MAX_LEVEL) {
mLogger.log(this.debugMode, "MissionCardComp", "pool already max", this.poolLv);
return;
}
const cost = this.getUpgradeCost(this.poolLv);
const currentCoin = this.getMissionCoin();
if (currentCoin < cost) {
oops.gui.toast(`金币不足,升级需要${cost}`);
this.updateCoinAndCostUI();
mLogger.log(this.debugMode, "MissionCardComp", "pool upgrade coin not enough", {
poolLv: this.poolLv,
currentCoin,
cost
});
return;
}
this.setMissionCoin(currentCoin - cost);
this.poolLv += 1;
this.playCoinChangeAnim(false);
this.updateCoinAndCostUI();
mLogger.log(this.debugMode, "MissionCardComp", "pool level up", {
poolLv: this.poolLv,
cost,
leftCoin: this.getMissionCoin()
});
}
private initCardsPanelPos() {
if (!this.cards_node || !this.cards_node.isValid) return;
if (!this.hasCachedCardsBaseScale) {
const scale = this.cards_node.scale;
this.cardsBaseScale = new Vec3(scale.x, scale.y, scale.z);
this.hasCachedCardsBaseScale = true;
}
this.cardsShowScale = new Vec3(this.cardsBaseScale.x, this.cardsBaseScale.y, this.cardsBaseScale.z);
this.cardsHideScale = new Vec3(0, 0, this.cardsBaseScale.z);
}
private enterPreparePhase() {
if (!this.cards_node || !this.cards_node.isValid) return;
this.initCardsPanelPos();
this.cards_node.active = true;
Tween.stopAllByTarget(this.cards_node);
this.cards_node.setScale(this.cardsShowScale);
}
private enterBattlePhase() {
if (!this.cards_node || !this.cards_node.isValid) return;
this.initCardsPanelPos();
this.cards_node.active = true;
Tween.stopAllByTarget(this.cards_node);
this.cards_node.setScale(this.cardsShowScale);
tween(this.cards_node)
.to(this.cardsPanelMoveDuration, {
scale: this.cardsHideScale
})
.start();
}
/** 构建本次抽卡结果保证最终可分发4条数据 */
private buildDrawCards(): CardConfig[] {
const cards = getCardsByLv(this.poolLv);
/** 正常情况下直接取前4 */
if (cards.length >= 4) return cards.slice(0, 4);
/** 兜底当返回不足4张时循环补齐保证分发不缺位 */
const filled = [...cards];
while (filled.length < 4) {
const fallback = getCardsByLv(this.poolLv);
if (fallback.length === 0) break;
filled.push(fallback[filled.length % fallback.length]);
}
return filled;
}
private tryRefreshHeroCards(heroType?: HType, targetPoolLv?: number): boolean {
const cards = drawCardsByRule(this.poolLv, {
count: 4,
type: CardType.Hero,
heroType,
targetPoolLv
});
if (cards.length <= 0) return false;
this.layoutCardSlots();
this.dispatchCardsToSlots(cards.slice(0, 4));
return true;
}
private tryRefreshHeroCardsByEffect(refreshHeroType: SpecialRefreshHeroType, refreshLv: number): boolean {
const heroType = this.resolveRefreshHeroType(refreshHeroType);
const targetPoolLv = refreshLv > 0 ? refreshLv : undefined;
return this.tryRefreshHeroCards(heroType, targetPoolLv);
}
private resolveRefreshHeroType(refreshHeroType: SpecialRefreshHeroType): HType | undefined {
if (refreshHeroType === SpecialRefreshHeroType.Melee) return HType.Melee;
if (refreshHeroType === SpecialRefreshHeroType.Ranged) return HType.Long;
return undefined;
}
/** 全量分发给4槽每个槽位是否接收由 CardComp 自己判断(锁定可跳过) */
private dispatchCardsToSlots(cards: CardConfig[]) {
for (let i = 0; i < this.cardComps.length; i++) {
const accepted = this.cardComps[i].applyDrawCard(cards[i] ?? null);
mLogger.log(this.debugMode, "MissionCardComp", "dispatch card", {
index: i,
card: cards[i]?.uuid ?? 0,
accepted
});
}
}
/** 系统清空4槽用于任务切换 */
private clearAllCards() {
this.cardComps.forEach(comp => comp.clearBySystem());
}
private layoutCardSlots() {
const count = this.cardComps.length;
if (count === 0) return;
const startX = -((count - 1) * this.cardWidth) / 2;
for (let i = 0; i < count; i++) {
const x = startX + i * this.cardWidth;
this.cardComps[i].setSlotPosition(x);
}
mLogger.log(this.debugMode, "MissionCardComp", "layout card slots", {
count,
cardWidth: this.cardWidth,
startX
});
}
private playButtonPressAnim(node: Node | null) {
this.playNodeScaleTo(node, this.buttonPressScale, 0.06);
}
private playButtonClickAnim(node: Node | null, onComplete: () => void) {
if (!node || !node.isValid) {
onComplete();
return;
}
this.playNodeScalePop(node, this.buttonClickScale, 0.05, 0.08, onComplete);
}
private playButtonResetAnim(node: Node | null) {
this.playNodeScaleTo(node, this.buttonNormalScale, 0.08);
}
private resetButtonScale(node: Node | null) {
if (!node || !node.isValid) return;
Tween.stopAllByTarget(node);
node.setScale(this.buttonNormalScale, this.buttonNormalScale, 1);
}
private canUpPool() {
if (this.poolLv >= CARD_POOL_MAX_LEVEL) return false;
const currentCoin = this.getMissionCoin();
return currentCoin >= this.getUpgradeCost(this.poolLv);
}
private canDrawCards() {
return this.getMissionCoin() >= this.getRefreshCost();
}
/** 更新升级按钮上的等级文案,反馈当前卡池层级 */
private updatePoolLvUI() {
if (this.cards_up) {
const nobg = this.cards_up.getChildByName("nobg");
if (nobg) {
nobg.active = !this.canUpPool();
}
const coinNode = this.cards_up.getChildByName("coin");
const label = coinNode?.getChildByName("num")?.getComponent(Label);
if (this.poolLv >= CARD_POOL_MAX_LEVEL) {
if (label) {
label.string = `0`;
}
} else {
if (label) {
label.string = `${this.getUpgradeCost(this.poolLv)}`;
}
}
}
if (this.pool_lv_node) {
this.pool_lv_node.active = true;
const lv = Math.max(CARD_POOL_INIT_LEVEL, Math.min(CARD_POOL_MAX_LEVEL, Math.floor(this.poolLv)));
this.pool_lv_node.getComponent(Label).string = `${lv}`;
const peak = 1.2
this.playHeroNumNodePop( this.pool_lv_node, peak);
}
mLogger.log(this.debugMode, "MissionCardComp", "pool lv ui update", {
poolLv: this.poolLv,
cost: this.getUpgradeCost(this.poolLv)
});
}
private updateDrawCostUI() {
if (!this.cards_chou) return;
const nobg = this.cards_chou.getChildByName("nobg");
if (nobg) {
nobg.active = !this.canDrawCards();
}
const coinNode = this.cards_chou.getChildByName("coin");
const numLabel = coinNode?.getChildByName("num")?.getComponent(Label);
if (numLabel) {
numLabel.string = `${this.getRefreshCost()}`;
}
}
private updateCoinAndCostUI() {
this.updatePoolLvUI();
this.updateDrawCostUI();
}
private playCoinChangeAnim(isIncrease: boolean) {
if (!this.coins_node || !this.coins_node.isValid) return;
const icon = this.coins_node.getChildByName("icon");
if (!icon || !icon.isValid ) return;
const peak = isIncrease ? 1.2 : 1.2;
this.playHeroNumNodePop(icon, peak);
const num= this.coins_node.getChildByName("num");
if (!num || !num.isValid) return;
this.playHeroNumNodePop(num, peak);
}
private getUpgradeCost(lv: number): number {
const baseCost = Math.max(0, Math.floor(CardsUpSet[lv] ?? 0));
const completedWave = Math.max(0, this.getCurrentWave() - 1);
const discount = Math.max(0, Math.floor(CARD_POOL_UPGRADE_DISCOUNT_PER_WAVE)) * completedWave;
return Math.max(0, baseCost - discount);
}
private getRefreshCost(): number {
return Math.max(0, Math.floor(this.refreshCost));
}
private ensureHeroInfoPanel(eid: number, model: HeroAttrsComp) {
if (!this.hero_info_node || !this.hero_info_prefab) return;
this.hero_info_node.active = true;
const current = this.heroInfoItems.get(eid);
if (current) {
current.model = model;
current.comp.bindData(eid, model);
this.updateHeroInfoPanel(current);
return;
}
const node = instantiate(this.hero_info_prefab);
node.parent = this.hero_info_node;
node.active = true;
const comp = node.getComponent(HInfoComp);
if (!comp) {
node.destroy();
return;
}
const item = {
node,
model,
comp
};
comp.bindData(eid, model);
this.heroInfoItems.set(eid, item);
this.relayoutHeroInfoPanels();
this.updateHeroInfoPanel(item);
}
private refreshHeroInfoPanels() {
const removeKeys: number[] = [];
this.heroInfoItems.forEach((item, eid) => {
if (!item.node || !item.node.isValid) {
removeKeys.push(eid);
return;
}
if (item.model?.is_dead) {
if (item.node.isValid) item.node.destroy();
removeKeys.push(eid);
return;
}
if (!item.comp.isModelAlive()) {
if (item.node.isValid) item.node.destroy();
removeKeys.push(eid);
return;
}
this.updateHeroInfoPanel(item);
});
for (let i = 0; i < removeKeys.length; i++) {
this.heroInfoItems.delete(removeKeys[i]);
}
this.relayoutHeroInfoPanels();
this.updateHeroNumUI(false, false);
}
private updateHeroInfoPanel(item: {
node: Node,
model: HeroAttrsComp,
comp: HInfoComp
}) {
item.comp.refresh();
}
private relayoutHeroInfoPanels() {
const sortedItems = [...this.heroInfoItems.values()].sort((a, b) => {
const aEnt = (a.model as any)?.ent as ecs.Entity | undefined;
const bEnt = (b.model as any)?.ent as ecs.Entity | undefined;
const aView = aEnt?.get(HeroViewComp);
const bView = bEnt?.get(HeroViewComp);
const aMove = aEnt?.get(MoveComp);
const bMove = bEnt?.get(MoveComp);
const aFrontScore = aView?.node?.position?.x ?? -999999;
const bFrontScore = bView?.node?.position?.x ?? -999999;
if (aFrontScore !== bFrontScore) return aFrontScore - bFrontScore;
const aSpawnOrder = aMove?.spawnOrder ?? 0;
const bSpawnOrder = bMove?.spawnOrder ?? 0;
if (aSpawnOrder !== bSpawnOrder) return aSpawnOrder - bSpawnOrder;
const aEid = aEnt?.eid ?? 0;
const bEid = bEnt?.eid ?? 0;
return aEid - bEid;
});
for (let index = 0; index < sortedItems.length; index++) {
const item = sortedItems[index];
if (!item.node || !item.node.isValid) continue;
const pos = item.node.position;
item.node.setPosition(index * (this.heroInfoItemGap + this.heroInfoItemSpacing), pos.y, pos.z);
item.node.setSiblingIndex(index);
}
}
private clearHeroInfoPanels() {
this.heroInfoItems.forEach(item => {
if (item.node && item.node.isValid) {
item.node.destroy();
}
});
this.heroInfoItems.clear();
if (this.hero_info_node && this.hero_info_node.isValid) {
for (let i = this.hero_info_node.children.length - 1; i >= 0; i--) {
const child = this.hero_info_node.children[i];
if (child && child.isValid) child.destroy();
}
}
this.heroInfoSyncTimer = 0;
this.syncMissionHeroData(0);
this.updateHeroNumUI(false, false);
}
public setHeroMaxCount(max: number) {
const missionData = this.getMissionData();
if (!missionData) return;
const min = 5;
const limit = Math.max(min, missionData.hero_extend_max_num ?? 6);
const next = Math.max(min, Math.min(limit, Math.floor(max || min)));
if (next === missionData.hero_max_num) return;
missionData.hero_max_num = next;
this.updateHeroNumUI(true, false);
}
public tryExpandHeroMax(add: number = 1): boolean {
const missionData = this.getMissionData();
if (!missionData) return false;
const before = this.getMissionHeroMaxNum();
const next = before + Math.max(0, Math.floor(add));
this.setHeroMaxCount(next);
return this.getMissionHeroMaxNum() > before;
}
public canUseHeroCard(): boolean {
return this.getAliveHeroCount() < this.getMissionHeroMaxNum();
}
private getAliveHeroCount(): number {
let count = 0;
this.heroInfoItems.forEach(item => {
if (!item?.node || !item.node.isValid) return;
if (!item.comp?.isModelAlive()) return;
if (item.model?.is_dead) return;
count += 1;
});
return count;
}
private queryAliveHeroActors(): Array<{ model: HeroAttrsComp, view: HeroViewComp | null }> {
const actors: Array<{ model: HeroAttrsComp, view: HeroViewComp | null }> = [];
ecs.query(ecs.allOf(HeroAttrsComp)).forEach((entity: ecs.Entity) => {
const model = entity.get(HeroAttrsComp);
if (!model) return;
if (model.fac !== FacSet.HERO) return;
if (model.is_dead) return;
const view = entity.get(HeroViewComp);
actors.push({ model, view });
});
return actors;
}
private tryUpgradeOneHero(currentLv: number, targetLv: number): boolean {
const fromLv = Math.max(1, Math.floor(currentLv));
const toLv = Math.max(1, Math.floor(targetLv));
if (toLv <= fromLv) return false;
const candidates = this.queryAliveHeroActors().filter(item => item.model.lv === fromLv);
if (candidates.length === 0) return false;
const target = candidates[Math.floor(Math.random() * candidates.length)];
this.applyHeroLevel(target.model, toLv);
if (target.view) {
target.view.playBuff("buff_lvup");
}
return true;
}
private applyHeroLevel(model: HeroAttrsComp, targetLv: number) {
const hero = HeroInfo[model.hero_uuid];
if (!hero) return;
const nextLv = Math.max(1, Math.min(3, Math.floor(targetLv)));
const hpRate = model.hp_max > 0 ? model.hp / model.hp_max : 1;
model.lv = nextLv;
model.ap = hero.ap * nextLv;
model.hp_max = hero.hp * nextLv;
model.hp = Math.max(1, Math.floor(model.hp_max * Math.max(0, Math.min(1, hpRate))));
model.skills = {};
for (const key in hero.skills) {
const skill = hero.skills[key];
if (!skill) continue;
model.skills[skill.uuid] = { ...skill, lv: Math.max(0, skill.lv + nextLv - 2), ccd: 0 };
}
model.updateSkillDistanceCache();
model.dirty_hp = true;
oops.message.dispatchEvent(GameEvent.HeroLvUp, {
uuid: model.hero_uuid,
lv: nextLv
});
}
private updateHeroNumUI(animate: boolean, isIncrease: boolean) {
this.syncMissionHeroData();
if (!animate || !isIncrease) return;
this.playHeroNumGainAnim();
}
private playHeroNumGainAnim() {
if (!this.hero_num_node || !this.hero_num_node.isValid) return;
const iconNode = this.hero_num_node.getChildByName("icon");
const numNode = this.hero_num_node.getChildByName("num");
this.playHeroNumNodePop(iconNode, 1.2);
this.playHeroNumNodePop(numNode, 1.2);
}
private playHeroNumDeniedAnim() {
if (!this.hero_num_node || !this.hero_num_node.isValid) return;
const iconNode = this.hero_num_node.getChildByName("icon");
const numNode = this.hero_num_node.getChildByName("num");
this.playHeroNumNodePop(iconNode, 1.2);
this.playHeroNumNodePop(numNode, 1.2);
}
private playHeroNumNodePop(node: Node | null, scalePeak: number) {
this.playNodeScalePop(node, scalePeak, 0.08, 0.1);
}
private playNodeScaleTo(node: Node | null, scale: number, duration: number) {
if (!node || !node.isValid) return;
Tween.stopAllByTarget(node);
tween(node)
.to(duration, {
scale: new Vec3(scale, scale, 1)
})
.start();
}
private playNodeScalePop(node: Node | null, scalePeak: number, toPeakDuration: number, toNormalDuration: number, onPeak?: () => void) {
if (!node || !node.isValid) return;
Tween.stopAllByTarget(node);
node.setScale(1, 1, 1);
const seq = tween(node)
.to(toPeakDuration, { scale: new Vec3(scalePeak, scalePeak, 1) });
if (onPeak) {
seq.call(onPeak);
}
seq.to(toNormalDuration, { scale: new Vec3(1, 1, 1) })
.start();
}
private getMissionData(): any {
return smc?.vmdata?.mission_data ?? null;
}
private getMissionHeroNum(): number {
const missionData = this.getMissionData();
return Math.max(0, Math.floor(missionData?.hero_num ?? 0));
}
private getMissionCoin(): number {
const missionData = this.getMissionData();
return Math.max(0, Math.floor(missionData?.coin ?? 0));
}
private getCurrentWave(): number {
const missionData = this.getMissionData();
return Math.max(1, Math.floor(missionData?.level ?? 1));
}
private setMissionCoin(value: number) {
const missionData = this.getMissionData();
if (!missionData) return;
missionData.coin = Math.max(0, Math.floor(value));
}
private getMissionHeroMaxNum(): number {
const missionData = this.getMissionData();
return Math.max(5, Math.floor(missionData?.hero_max_num ?? 5));
}
private syncMissionHeroData(count?: number) {
const missionData = this.getMissionData();
if (!missionData) return;
const safeCount = Math.max(0, Math.floor(count ?? this.getAliveHeroCount()));
missionData.hero_num = safeCount;
}
/** 视图对象通过 ecs.Entity.remove(ModuleViewComp) 删除组件是触发组件处理自定义释放逻辑 */
reset() {
this.clearHeroInfoPanels();
this.resetButtonScale(this.cards_chou);
this.resetButtonScale(this.cards_up);
this.node.destroy();
}
}