Files
pixelheros/assets/script/game/map/MissionHeroComp.ts
pan 667795fd1a refactor(heroSpawn): 重构英雄出生点位分配逻辑
1. 新增按职业分三排出生规则:战士/坦克前排、射手/刺客中排、辅助/法师后排
2. 同职业重复召唤时按±20像素交替展开,避免落点重叠
3. 新增posIndex编码规则:role*10+n,同步登记heroGrid网格索引
4. 废弃旧的近战/远程分位逻辑,替换为职业映射规则
5. 修复旧版点位分配的槽位冲突问题,优化溢出兜底逻辑
2026-08-17 13:39:37 +08:00

448 lines
19 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 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最靠左
* 同一职业重复召唤时,从该职业基准点向两侧 ±20 依次交替展开,避免落点重叠。
* posIndex 语义role * 10 + nn 为同职业展开序0=居中1=+202=-203=+40…
* 同步登记 heroGrid 供 SCastSystem 网格索敌使用。
*
* 依赖:
* - Herohero/Hero.ts—— 英雄 ECS 实体类
* - HeroAttrsComp —— 英雄属性组件
* - HeroInfo / HeroPos / HType / HRoleheroSet—— 英雄静态配置
* - 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, Vec3> = {
[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 = 20
/** 同职业槽位步长posIndex = role * 10 + n */
private static readonly SLOT_STRIDE = 10
/** 英雄出生时的掉落高度(从空中落到地面的像素差) */
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;
}
// ======================== 英雄生成 ========================
/**
* 为英雄分配出生槽位:
* 按职业取三排基准点(战士/坦克前排、射手/刺客中排、辅助/法师后排),
* 同职业已存在时在基准点 ±20 的倍数次交替展开0, +20, -20, +40, -40 …)。
*
* @param role 英雄职业(决定所属排)
* @param heroes 当前存活英雄列表spawnHeroAt 在实体创建前快照,避免复用池残留实体干扰槽位判定)
* @returns { posIndex 槽位索引role*10+n-1 表示分配失败), landingPos 落地点 }
*/
private pickSpawnSlotForHero(role: HRole, heroes: Hero[]): { posIndex: number; landingPos: Vec3 } {
const basePos = MissionHeroComp.ROLE_ROW[role] ?? MissionHeroComp.HERO_ROW_MID;
// 只统计同职业英雄的展开序 nposIndex % 10避免不同职业间槽位互相挤占
const usedSlots = new Set<number>();
for (const h of heroes) {
const m = h.get(HeroAttrsComp)!;
if (m.role !== role || m.posIndex < 0) continue;
usedSlots.add(m.posIndex % MissionHeroComp.SLOT_STRIDE);
}
// DEBUG: 输出槽位判定过程,验证同职业展开是否生效(排查后删除)
console.log(`[SpawnSlot] role=${role} heroes=${heroes.length} used=[${[...usedSlots].join(",")}]`);
// 展开序 n0=居中1=+202=-203=+40 …,越界按无槽位落基准点(不写网格)
for (let n = 0; n < MissionHeroComp.SLOT_STRIDE; n++) {
const posIndex = role * MissionHeroComp.SLOT_STRIDE + n;
if (posIndex >= smc.mission.heroGrid.length) break;
if (!usedSlots.has(n)) {
const lp = this.slotIndexToLandingPos(posIndex);
console.log(`[SpawnSlot] → posIndex=${posIndex} landing=(${lp.x},${lp.y})`);
return { posIndex, landingPos: lp };
}
}
// 同职业槽位占满时溢出兜底落在该职业基准点posIndex=-1 表示未登记网格,
// 避免与友方技能遍历 heroGrid 时产生重复目标
return { posIndex: -1, landingPos: v3(basePos.x, basePos.y, 0) };
}
/**
* 生成一个英雄 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) {
let hero = ecs.getEntity<Hero>(Hero);
let scale = 1
const role = HeroInfo[uuid]?.role ?? HRole.Warrior;
let finalPosIndex = posIndex;
let landingPos: Vec3;
if (posIndex >= 0) {
// 指定槽位:按 role*10+n 反解排与偏移,落点与自动分配保持一致
landingPos = this.slotIndexToLandingPos(posIndex);
} 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);
// DEBUG: 验证实体创建后写入的实际槽位与节点位置(排查后删除)
{
const m = hero.get(HeroAttrsComp);
const v = hero.get(HeroViewComp);
console.log(`[SpawnSlot] spawned eid=${hero.eid} uuid=${uuid} posIndex=${m?.posIndex} node.x=${v?.node?.position.x}`);
}
// 召唤完成后,派发事件以更新英雄面板
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<void> {
return new Promise<void>((resolve) => {
// 以旧英雄节点位置作为合并目标点,保证动画收拢到原站位
const oldView = oldHero.get(HeroViewComp);
const birthPos = oldView?.node?.isValid
? oldView.node.getPosition()
: (posIndex >= 0 ? this.slotIndexToLandingPos(posIndex) : MissionHeroComp.HERO_ROW_MID);
oldHero.mergeToBirthAndDestroy(birthPos, () => {
// 旧实体销毁后 posIndex 已释放,直接按原站位生成新英雄
this.spawnHeroAt(uuid, mergedLv, card_lv, posIndex);
resolve();
});
});
}
/**
* 槽位索引 → 落点坐标role*10+n 反解,供合成动画目标点等兜底场景使用)。
* @param posIndex 槽位索引,越界时回退到中排基准点
*/
private slotIndexToLandingPos(posIndex: number): Vec3 {
const slotRole = Math.floor(posIndex / MissionHeroComp.SLOT_STRIDE) as HRole;
const n = posIndex % MissionHeroComp.SLOT_STRIDE;
const basePos = MissionHeroComp.ROLE_ROW[slotRole] ?? MissionHeroComp.HERO_ROW_MID;
const step = Math.ceil(n / 2);
const dir = n % 2 === 1 ? 1 : -1;
const offsetX = n === 0 ? 0 : dir * step * MissionHeroComp.SLOT_OFFSET;
return v3(basePos.x + offsetX, basePos.y, 0);
}
/** ECS 组件移除时触发(当前不销毁节点,保留引用) */
reset() {
// this.node.destroy();
}
}