refactor(mission): 重构英雄出生槽位分配逻辑

1.  解耦落点偏移与posIndex,改用全局网格下标作为posIndex
2.  调整同职业横向间隔为30像素,修正槽位编码溢出问题
3.  移除废弃的slotIndexToLandingPos方法与调试日志
4.  支持合成继承原英雄位置,避免阵型跳动
This commit is contained in:
pan
2026-08-17 14:51:49 +08:00
parent b154fb306c
commit af32f2901d

View File

@@ -23,9 +23,8 @@
* 出生点规则:
* 按职业HRole分三排战士/坦克 → 前排x=-200最靠右迎敌
* 射手/刺客 → 中排x=-260辅助/法师 → 后排x=-320最靠左
* 同一职业重复召唤时,从该职业基准点向两侧 ±20 依次交替展开,避免落点重叠。
* posIndex 语义role * 10 + nn 为同职业展开序0=居中1=+202=-203=+40…
* 同步登记 heroGrid 供 SCastSystem 网格索敌使用。
* 同一职业重复召唤时,从该职业基准点向两侧 ±SLOT_OFFSET 依次交替展开,避免落点重叠。
* posIndex 取 heroGrid 全局空闲下标0~9用于网格登记/索敌,落点偏移与 posIndex 解耦。
*
* 依赖:
* - Herohero/Hero.ts—— 英雄 ECS 实体类
@@ -94,9 +93,7 @@ export class MissionHeroComp extends CCComp {
};
/** 同职业重复召唤时的落点横向间隔(像素) */
private static readonly SLOT_OFFSET = 20
/** 同职业槽位步长posIndex = role * 10 + n */
private static readonly SLOT_STRIDE = 10
private static readonly SLOT_OFFSET = 30
/** 英雄出生时的掉落高度(从空中落到地面的像素差) */
private static readonly HERO_DROP_HEIGHT = 260
@@ -236,39 +233,49 @@ export class MissionHeroComp extends CCComp {
/**
* 为英雄分配出生槽位:
* 按职业取三排基准点(战士/坦克前排、射手/刺客中排、辅助/法师后排),
* 同职业已存在时在基准点 ±20 的倍数次交替展开0, +20, -20, +40, -40 …)。
* 同职业第 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 槽位索引role*10+n-1 表示分配失败, landingPos 落地点 }
* @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;
// 只统计同职业英雄的展开序 nposIndex % 10避免不同职业间槽位互相挤占
const usedSlots = new Set<number>();
// 同职业已占位的展开序集合(依据落点 x 反推,与 posIndex 解耦)
const usedOffsets = new Set<number>();
const occupiedGrid = 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 };
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));
}
}
// 同职业槽位占满时溢出兜底落在该职业基准点posIndex=-1 表示未登记网格,
// 避免与友方技能遍历 heroGrid 时产生重复目标
return { posIndex: -1, landingPos: v3(basePos.x, basePos.y, 0) };
// 展开序 n0=居中1=+202=-203=+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 };
}
/**
@@ -296,16 +303,16 @@ export class MissionHeroComp extends CCComp {
* @param posIndex 指定站位索引,-1 表示自动分配
* @returns 创建的 Hero 实体
*/
private spawnHeroAt(uuid: number, hero_lv: number, card_lv: number, posIndex: number) {
private spawnHeroAt(uuid: number, hero_lv: number, card_lv: number, posIndex: number, inheritPos: Vec3 | null = null) {
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);
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);
@@ -318,12 +325,6 @@ export class MissionHeroComp extends CCComp {
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);
@@ -413,32 +414,17 @@ export class MissionHeroComp extends CCComp {
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);
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);
// 旧实体销毁后沿用其站位与 posIndex 生成新英雄,避免阵型跳动
this.spawnHeroAt(uuid, mergedLv, card_lv, posIndex, oldPos);
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() {