/** * @file MissionHeroComp.ts * @description 英雄召唤管理组件(逻辑层 + 视图层) * * 职责: * 1. 处理 **英雄召唤**:接收 CallHero 事件 → 通过串行队列执行召唤。 * 2. 管理英雄的出生点和掉落动画。 * * 关键设计: * - summon_queue + processSummonQueue() 确保召唤请求 **串行处理**。 * - handleSingleSummon() 负责生成英雄;同 uuid 重复召唤走二合一合成升级。 * * 合成规则: * 场上存在同 uuid 英雄时再次召唤触发二合一: * 旧英雄移动合并销毁,新英雄以 **两者最高等级 + 1** 落地(封顶 HERO_MAX_LV), * 等级已达上限时拒绝召唤。英雄也可通过升级卡(SpecialUpgrade)升级。 * * 死亡机制: * 英雄为真实死亡(HeroAtkSystem.doDead → 快照至 smc.mission.dead_heroes → 实体销毁), * 本组件不做回合自动复活;复活由后续复活机制读取 dead_heroes 重新召唤。 * revive 系技能为"濒死回血"(血量归零时消耗次数回血续命),不触发真死。 * * 出生点规则: * 按职业(HRole)分三排:战士/坦克 → 前排(x=-200,最靠右迎敌), * 射手/刺客 → 中排(x=-260),辅助/法师 → 后排(x=-320,最靠左)。 * 同一职业重复召唤时,从该职业基准点向两侧 ±SLOT_OFFSET 依次交替展开,避免落点重叠。 * posIndex 取 heroGrid 全局空闲下标(0~9)用于网格登记/索敌,落点偏移与 posIndex 解耦。 * * 依赖: * - Hero(hero/Hero.ts)—— 英雄 ECS 实体类 * - HeroAttrsComp —— 英雄属性组件 * - HeroInfo / HeroPos / HType / HRole(heroSet)—— 英雄静态配置 * - FightSet —— 战斗常量 */ import { _decorator, instantiate, Prefab, v3, Vec3, BoxCollider2D } 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 { Hero } from "../hero/Hero"; import { smc } from "../common/SingletonModuleComp"; import { Timer } from "db://oops-framework/core/common/timer/Timer"; import { GameEvent } from "../common/config/GameEvent"; import { HeroInfo, HRole } from "../common/config/heroSet"; import { oops } from "../../../../extensions/oops-plugin-framework/assets/core/Oops"; import { HeroAttrsComp } from "../hero/HeroAttrsComp"; import { FacSet, FightSet, BoxSet } from "../common/config/GameSet"; import { HeroViewComp } from "../hero/HeroViewComp"; import { FieldSkillSet, FieldSkillType } from "../common/config/SkillSet"; import { MoveComp } from "../hero/MoveComp"; const { ccclass } = _decorator; /** * MissionHeroComp —— 英雄召唤管理器 * * 管理英雄的召唤请求队列和出生动画。 * 英雄升级由 MissionCardComp 的升级卡系统统一处理。 */ @ccclass('MissionHeroComp') @ecs.register('MissionHeroComp', false) export class MissionHeroComp extends CCComp { // ======================== 常量 ======================== /** * 三排出生基准点(y 均为 BoxSet.GAME_LINE): * - 前排 x=-200(最靠右迎敌):战士 / 坦克 * - 中排 x=-260:射手 / 刺客 * - 后排 x=-320(最靠左):辅助 / 法师 */ public static readonly HERO_ROW_FRONT = v3(-200, BoxSet.GAME_LINE, 0) public static readonly HERO_ROW_MID = v3(-260, BoxSet.GAME_LINE, 0) public static readonly HERO_ROW_BACK = v3(-320, BoxSet.GAME_LINE, 0) /** * 英雄登场占位点(兼容旧引用,等价于后/中/前三排基准点): * - index 0=后排、1=中排、2=前排。 */ public static readonly HERO_POSITIONS: Vec3[] = [ MissionHeroComp.HERO_ROW_BACK, MissionHeroComp.HERO_ROW_MID, MissionHeroComp.HERO_ROW_FRONT, ]; /** * 职业 → 出生排映射: * 战士/坦克 → 前排,射手/刺客 → 中排,辅助/法师 → 后排。 */ private static readonly ROLE_ROW: Record = { [HRole.Tank]: MissionHeroComp.HERO_ROW_FRONT, [HRole.Warrior]: MissionHeroComp.HERO_ROW_FRONT, [HRole.Assassin]: MissionHeroComp.HERO_ROW_MID, [HRole.Archer]: MissionHeroComp.HERO_ROW_MID, [HRole.Mage]: MissionHeroComp.HERO_ROW_BACK, [HRole.Support]: MissionHeroComp.HERO_ROW_BACK, }; /** 同职业重复召唤时的落点横向间隔(像素) */ private static readonly SLOT_OFFSET = 30 /** 英雄出生时的掉落高度(从空中落到地面的像素差) */ private static readonly HERO_DROP_HEIGHT = 260 // ======================== 运行时属性 ======================== /** 预留计时器 */ timer: Timer = new Timer(2) /** 预留状态:友方是否全部死亡 */ Friend_is_dead: boolean = false /** 当前处理的英雄 uuid */ current_hero_uuid: number = 0 /** 当前英雄数量缓存 */ current_hero_num: number = -1 /** 是否正在消费召唤队列(防止并发) */ is_processing_queue: boolean = false /** 召唤请求队列:保证召唤按顺序串行执行 */ summon_queue: { uuid: number; hero_lv: number; card_lv: number }[] = [] /** 预留英雄列表 */ heros: any = [] // ======================== 生命周期 ======================== onLoad() { // 注册节点级事件 this.on(GameEvent.FightReady, this.fight_ready, this) this.on(GameEvent.Zhaohuan, this.zhao_huan, this) this.on(GameEvent.MissionEnd, this.clear_heros, this) // 注册全局消息 oops.message.on(GameEvent.CallHero, this.call_hero, this) oops.message.on("PhasePrepareStart", this.fight_ready, this) } onDestroy() { super.onDestroy(); // 清理全部监听 oops.message.off(GameEvent.CallHero, this.call_hero, this) oops.message.off("PhasePrepareStart", this.fight_ready, this) } start() { } // ======================== 事件处理 ======================== /** 关卡结束时清理全部存活英雄 ECS 实体,并清空本局死亡英雄记录 */ clear_heros() { const heroes = this.getAllHeroes(); for (let i = 0; i < heroes.length; i++) { heroes[i].destroy(); } // 整局结束,死亡记录失效(复活机制仅限局内) smc.mission.dead_heroes = []; } /** * 战斗准备阶段:刷新存活英雄计数与血条。 * * 说明:英雄为真实死亡(实体已销毁,快照存于 smc.mission.dead_heroes), * 本阶段不再做回合自动复活;复活由后续复活机制(重新召唤)显式触发。 */ fight_ready() { const heroes = this.getAllHeroes(); smc.vmdata.mission_data.hero_num = heroes.length; for (let i = 0; i < heroes.length; i++) { const model = heroes[i].get(HeroAttrsComp); if (model) { model.dirty_hp = true; } } } /** 预留:召唤事件扩展入口 */ private zhao_huan(event: string, args: any) { } /** * 召唤请求入口: * 从事件参数中提取 uuid / hero_lv / card_lv,放入串行队列。 * 二合一:同 uuid 已召唤且等级未达上限时允许入队,由 handleSingleSummon 执行合成; * 同 uuid 且等级已达上限时拒绝(只能通过升级卡继续提升之外无法再合成)。 * * @param event 事件名 * @param args { uuid, hero_lv, card_lv } */ private async call_hero(event: string, args: any) { const payload = args ?? event; const uuid = Number(payload?.uuid ?? 1001); // 局内登场等级统一以局外成长等级为准(升级后登场即为升级后的等级) // payload.hero_lv 保留作为下限兜底,防止外部显式指定更高等级 const outer_lv = smc.getHeroOuterLv(uuid); const hero_lv = Math.max(outer_lv, Math.max(1, Number(payload?.hero_lv ?? 1))); const card_lv = Math.max(1, Number(payload?.card_lv ?? 1)); // 二合一前置校验:同 uuid 已存在且合成后等级会超上限时,直接拒绝 if (this.isHeroAlreadySummoned(uuid)) { const existing = this.findHeroByUuid(uuid); const existingLv = existing ? (existing.get(HeroAttrsComp)?.lv ?? 1) : 1; if (Math.max(existingLv, hero_lv) + 1 > FightSet.HERO_MAX_LV) { oops.gui.toast(`该英雄已达等级上限,无法继续合成`); return; } } this.summon_queue.push({ uuid, hero_lv, card_lv }); this.processSummonQueue(); } /** * 检查场上是否已存在指定 uuid 的存活英雄。 * @param uuid 英雄模板 uuid * @returns true 表示场上已有该英雄,不可重复召唤 */ private isHeroAlreadySummoned(uuid: number): boolean { return this.findHeroByUuid(uuid) !== null; } /** * 查找场上指定 uuid 的存活英雄实体。 * @param uuid 英雄模板 uuid * @returns 命中的 Hero 实体,未命中返回 null */ private findHeroByUuid(uuid: number): Hero | null { let found: Hero | null = null; ecs.query(ecs.allOf(HeroAttrsComp)).forEach((entity: ecs.Entity) => { if (found) return; const model = entity.get(HeroAttrsComp); if (!model || model.fac !== FacSet.HERO || model.is_dead) return; if (model.hero_uuid === uuid) found = entity as Hero; }); return found; } // ======================== 英雄生成 ======================== /** * 为英雄分配出生槽位: * 按职业取三排基准点(战士/坦克前排、射手/刺客中排、辅助/法师后排), * 同职业第 1 个落基准点,之后按 ±SLOT_OFFSET 交替展开(0, +20, -20, +40, -40 …)。 * * 槽位编码:posIndex 直接取 heroGrid 的全局空闲下标(0~9),不做 role 分段, * 避免 role*10 编码超出 heroGrid 容量(长度 10)导致非坦克职业全部越界兜底。 * 落点偏移由「同职业已占位英雄数」推展开序 n 计算,与 posIndex 解耦。 * * @param role 英雄职业(决定所属排) * @param heroes 当前存活英雄列表(spawnHeroAt 在实体创建前快照,避免复用池残留实体干扰槽位判定) * @returns { posIndex heroGrid 空闲下标(-1 表示满员未登记), landingPos 落地点 } */ private pickSpawnSlotForHero(role: HRole, heroes: Hero[]): { posIndex: number; landingPos: Vec3 } { const basePos = MissionHeroComp.ROLE_ROW[role] ?? MissionHeroComp.HERO_ROW_MID; // 同职业已占位的展开序集合(依据落点 x 反推,与 posIndex 解耦) const usedOffsets = new Set(); const occupiedGrid = new Set(); for (const h of heroes) { const m = h.get(HeroAttrsComp)!; if (m.posIndex >= 0) occupiedGrid.add(m.posIndex); if (m.role !== role) continue; const view = h.get(HeroViewComp); if (view?.node?.isValid) { usedOffsets.add(Math.round(view.node.position.x - basePos.x)); } } // 展开序 n:0=居中,1=+20,2=-20,3=+40 …,找到第一个未被同职业占用的偏移 // 上限取 heroGrid 容量,同职业超过该数量时复用基准点(不再向外展开) let offsetX = 0; for (let n = 0; n < smc.mission.heroGrid.length; n++) { const step = Math.ceil(n / 2); const dir = n % 2 === 1 ? 1 : -1; const candidate = n === 0 ? 0 : dir * step * MissionHeroComp.SLOT_OFFSET; if (!usedOffsets.has(candidate)) { offsetX = candidate; break; } } const landingPos = v3(basePos.x + offsetX, basePos.y, 0); // posIndex 取 heroGrid 全局空闲下标,仅用于网格登记/索敌,与落点无映射 let posIndex = -1; for (let i = 0; i < smc.mission.heroGrid.length; i++) { if (smc.mission.heroGrid[i] < 0 && !occupiedGrid.has(i)) { posIndex = i; break; } } return { posIndex, landingPos }; } /** * 生成一个英雄 ECS 实体: * - 计算出生点(空中)和落点(地面)。 * - 调用 hero.load() 初始化并播放掉落动画。 * * @param uuid 英雄 UUID * @param hero_lv 英雄等级 * @param card_lv 卡牌等级(驱动 bg_node 颜色) * @returns 创建的 Hero 实体 */ private addHero(uuid: number = 1001, hero_lv: number = 1, card_lv: number = 1) { return this.spawnHeroAt(uuid, hero_lv, card_lv, -1); } /** * 在指定站位生成英雄: * - posIndex < 0 时按职业自动分配空位(三排基准点 ±20 展开)。 * - 计算出生点(空中)和落点(地面),播放掉落动画。 * * @param uuid 英雄 UUID * @param hero_lv 英雄等级 * @param card_lv 卡牌等级(驱动 bg_node 颜色) * @param posIndex 指定站位索引,-1 表示自动分配 * @returns 创建的 Hero 实体 */ private spawnHeroAt(uuid: number, hero_lv: number, card_lv: number, posIndex: number, inheritPos: Vec3 | null = null) { let hero = ecs.getEntity(Hero); let scale = 1 const role = HeroInfo[uuid]?.role ?? HRole.Warrior; let finalPosIndex = posIndex; let landingPos: Vec3; if (inheritPos) { // 合成继承:沿用旧英雄节点位置,posIndex 透传登记网格 landingPos = v3(inheritPos.x, MissionHeroComp.ROLE_ROW[role]?.y ?? BoxSet.GAME_LINE, 0); } else { const heroes = this.getAllHeroes().filter(h => { const m = h.get(HeroAttrsComp); return !!m && !m.is_dead; }); const slot = this.pickSpawnSlotForHero(role, heroes); finalPosIndex = slot.posIndex; landingPos = slot.landingPos; } let spawnPos: Vec3 = v3(landingPos.x, landingPos.y + MissionHeroComp.HERO_DROP_HEIGHT, 0); hero.load(spawnPos, scale, uuid, landingPos.y, hero_lv, card_lv, finalPosIndex); // 召唤完成后,派发事件以更新英雄面板 const model = hero.get(HeroAttrsComp); if (model) { oops.message.dispatchEvent(GameEvent.MasterCalled, { eid: hero.eid, model: model }); } return hero; } // ======================== 英雄查询 ======================== /** 获取当前全部友方英雄 ECS 实体列表(包括存活和墓地) */ private getAllHeroes(): Hero[] { const heroes: Hero[] = []; ecs.query(ecs.allOf(HeroAttrsComp)).forEach((entity: ecs.Entity) => { const model = entity.get(HeroAttrsComp); if (!model) return; if (model.fac !== FacSet.HERO) return; heroes.push(entity as Hero); }); return heroes; } // ======================== 召唤队列 ======================== /** * 串行消费召唤队列: * 使用 is_processing_queue 标志防止同帧多次调用。 * 逐个取出队列中的请求并处理。 */ private async processSummonQueue() { if (this.is_processing_queue) return; this.is_processing_queue = true; try { while (this.summon_queue.length > 0) { const payload = this.summon_queue.shift(); if (!payload) continue; await this.handleSingleSummon(payload.uuid, payload.hero_lv, payload.card_lv); } } finally { this.is_processing_queue = false; } } /** * 处理单次召唤: * - 场上无同 uuid 英雄:直接生成新英雄。 * - 场上已有同 uuid 英雄:触发二合一,旧英雄合并销毁后生成等级 +1 的新英雄。 * * @param uuid 英雄 UUID * @param hero_lv 英雄等级 * @param card_lv 卡牌等级 */ private async handleSingleSummon(uuid: number, hero_lv: number, card_lv: number = 1) { const existing = this.findHeroByUuid(uuid); if (!existing) { this.addHero(uuid, hero_lv, card_lv); return; } const oldModel = existing.get(HeroAttrsComp); const oldLv = oldModel?.lv ?? 1; // 合成后等级 = 两者最高等级 + 1,封顶 HERO_MAX_LV const mergedLv = Math.min(FightSet.HERO_MAX_LV, Math.max(oldLv, hero_lv) + 1); // 继承旧英雄站位,避免新英雄重新抢位导致阵型跳动 const posIndex = oldModel?.posIndex ?? -1; await this.mergeHeroAndRespawn(existing, uuid, mergedLv, card_lv, posIndex); } /** * 二合一合成流程: * 1. 旧英雄播放移动合并动画并销毁(释放站位与实体)。 * 2. 在原站位生成 mergedLv 级新英雄并播放入场动画。 * * @param oldHero 场上已有的同 uuid 英雄 * @param uuid 英雄 UUID * @param mergedLv 合成后的英雄等级 * @param card_lv 卡牌等级 * @param posIndex 继承的站位索引(-1 表示重新分配) */ private mergeHeroAndRespawn(oldHero: Hero, uuid: number, mergedLv: number, card_lv: number, posIndex: number): Promise { return new Promise((resolve) => { // 以旧英雄节点位置作为合并目标点,保证动画收拢到原站位 const oldView = oldHero.get(HeroViewComp); const oldPos = oldView?.node?.isValid ? oldView.node.getPosition() : null; const birthPos = oldPos ?? MissionHeroComp.HERO_ROW_MID; oldHero.mergeToBirthAndDestroy(birthPos, () => { // 旧实体销毁后沿用其站位与 posIndex 生成新英雄,避免阵型跳动 this.spawnHeroAt(uuid, mergedLv, card_lv, posIndex, oldPos); resolve(); }); }); } /** ECS 组件移除时触发(当前不销毁节点,保留引用) */ reset() { // this.node.destroy(); } }