refactor(monster): 重构移动组件体系,统一英雄与怪物移动逻辑

1. 删除废弃的 MonMoveComp,将功能合并到 MoveComp 中
2. 移除 MissionComp 中冗余的怪物最大数量配置计算
3. 优化英雄站位分配逻辑,近战优先靠前站位
4. 统一移动系统的边界约束与渲染排序逻辑
5. 清理 Hero.ts 中未使用的导入项
This commit is contained in:
pan
2026-08-13 09:20:27 +08:00
parent c27c81cfa6
commit 038abdc5d2
8 changed files with 102 additions and 613 deletions

View File

@@ -1,26 +1,31 @@
/**
* @file MoveComp.ts
* @description 统一移动组件与系统(英雄 + 怪物)
*
* 核心规则:
* 1. 准备阶段:双方原地待命,不推进不索敌。
* 2. 战斗阶段双方向最近敌人推进进入攻击范围model.dis后停止移动并攻击。
* 3. 场上无敌人:停止移动(战斗结束,由 MissionComp 进入结算)。
* 4. 击退/位移不会把单位推出屏幕HERO 不越过 BoxSet.LETF_ENDMON 不越过 BoxSet.RIGHT_END。
* 5. 推进方向无上限限制,直到接触敌人或抵达敌方边界。
*/
import { ecs } from "../../../../extensions/oops-plugin-framework/assets/libs/ecs/ECS";
import { HeroViewComp } from "./HeroViewComp";
import { HeroAttrsComp } from "./HeroAttrsComp";
import { smc } from "../common/SingletonModuleComp";
import { BoxSet, FacSet } from "../common/config/GameSet";
import { HeroDisVal, HType, HRole } from "../common/config/heroSet";
import { BoxCollider2D, Node } from "cc";
import { MonMoveComp } from "./MonMoveComp";
import { Node } from "cc";
@ecs.register('MoveComp')
export class MoveComp extends ecs.Comp {
/** 朝向1=向右-1=向左 */
/** 朝向1=向右HERO-1=向左MON由系统根据 fac 自动维护 */
direction: number = 1;
/** 当前移动目标 X(战斗/回位都会更新) */
/** 当前移动目标 X */
targetX: number = 0;
/** 是否允许移动(出生落地前会短暂关闭) */
moving: boolean = true;
/** 预留:目标 Y当前逻辑主要使用 baseY 锁定地平线) */
targetY: number = 0;
/** 角色所属车道的地平线 Y移动时强制贴地 */
/** 站位基准 Y移动时强制贴地 */
baseY: number = 0;
/** 预留:车道索引 */
lane: number = 0;
/** 出生序,用于同条件渲染排序稳定 */
spawnOrder: number = 0;
@@ -28,68 +33,20 @@ export class MoveComp extends ecs.Comp {
this.direction = 1;
this.targetX = 0;
this.moving = true;
this.targetY = 0;
this.baseY = 0;
this.lane = 0;
this.spawnOrder = 0;
}
}
interface MoveFacConfig {
/** 阵营可前进边界(靠近敌方一侧) */
moveFrontX: number;
/** 阵营可后退边界(靠近己方一侧) */
moveBackX: number;
/** 被逼退时前侧安全边界 */
retreatFrontX: number;
/** 被逼退时后侧安全边界 */
retreatBackX: number;
}
@ecs.register('MoveSystem')
export class MoveSystem extends ecs.ComblockSystem implements ecs.ISystemUpdate {
private readonly heroFrontAnchorX = -200;
private readonly monFrontAnchorX = 0;
/** 常规同阵营横向最小间距(英雄) */
private readonly heroAllySpacingX = 100;
/** 常规同阵营横向最小间距(怪物) */
private readonly monAllySpacingX = 75;
/** 纵向判定为同排的最大 Y 差 */
private readonly minSpacingY = 30;
/** 渲染层级重排节流,避免每帧排序 */
private readonly renderSortInterval = 0.05;
private lastRenderSortAt = 0;
private heroMoveMatcher: ecs.IMatcher | null = null;
private heroViewMatcher: ecs.IMatcher | null = null;
private readonly renderEntries: { node: Node; bossPriority: number; frontScore: number; spawnOrder: number; eid: number; laneScore: number }[] = [];
private renderEntryCount = 0;
/**
* 阵营可移动边界配置。
* HERO 在左侧出生并向右推进MON 在右侧出生并向左推进。
*/
private readonly facConfigs: Record<number, MoveFacConfig> = {
[FacSet.HERO]: {
moveFrontX: 999999,
moveBackX: -999999,
retreatFrontX: 999999,
retreatBackX: -999999,
},
[FacSet.MON]: {
moveFrontX: -999999,
moveBackX: 999999,
retreatFrontX: -999999,
retreatBackX: 999999,
}
};
private getHeroMoveMatcher(): ecs.IMatcher {
if (!this.heroMoveMatcher) {
this.heroMoveMatcher = ecs.allOf(HeroAttrsComp, HeroViewComp, MoveComp);
}
return this.heroMoveMatcher;
}
private getHeroViewMatcher(): ecs.IMatcher {
if (!this.heroViewMatcher) {
this.heroViewMatcher = ecs.allOf(HeroAttrsComp, HeroViewComp);
@@ -109,52 +66,49 @@ export class MoveSystem extends ecs.ComblockSystem implements ecs.ISystemUpdate
const move = e.get(MoveComp);
const view = e.get(HeroViewComp);
if (!model || !move || !view || !view.node) return;
if (model.fac !== FacSet.HERO) return; // 只处理英雄移动
if (!move.moving) return;
if (model.is_stop || model.is_dead || model.is_reviving || model.isFrost()) {
/** 关卡阶段性冻结怪物行为 */
if (smc.mission.stop_mon_action && model.fac === FacSet.MON) {
this.clearCombatTarget(model);
view.status_change("idle");
return;
}
/** 死亡/复活/控制状态:停止移动与索敌 */
if (model.is_stop || model.is_dead || model.is_reviving || model.isFrost() || model.isStun()) {
this.clearCombatTarget(model);
if (!model.is_reviving) view.status_change("idle");
return;
}
// 1. 获取全局排位目标
const slot = this.getGlobalFormationSlot(e, model);
move.baseY = slot.targetY;
move.targetX = slot.targetX;
// 2. 平滑 Y 轴换路(不分阶段,立即响应编队变化)
let isChangingLane = false;
if (Math.abs(view.node.position.y - move.baseY) > 2) {
const currentY = view.node.position.y;
const deltaY = move.baseY - currentY;
const step = 400 * this.dt; // 换路速度
const newY = currentY + Math.sign(deltaY) * Math.min(Math.abs(deltaY), step);
view.node.setPosition(view.node.position.x, newY, 0);
isChangingLane = true;
} else {
/** 所有移动都锁定在 baseY避免出现“漂移” */
if (view.node.position.y !== move.baseY) {
view.node.setPosition(view.node.position.x, move.baseY, 0);
}
// 渲染层级重排
this.updateRenderOrder();
/** 准备阶段:双方原地待命,不推进不索敌 */
if (!smc.mission.in_fight) {
this.clearCombatTarget(model);
model.is_atking = false;
if (view.status !== "atk") view.status_change("idle");
return;
}
/** 战斗阶段:索敌并推进 */
const nearestEnemy = this.findNearestEnemy(e);
if (nearestEnemy) {
/** 有敌人:进入战斗位移逻辑(立即向编队目标移动) */
this.processCombatLogic(e, move, view, model, nearestEnemy);
this.syncCombatTarget(model, view, nearestEnemy);
} else {
/** 无敌人:清目标并回归编队站位 */
/** 无敌人:停止移动,等待战斗结束结算 */
this.clearCombatTarget(model);
this.moveToSlot(view, move, model, move.targetX);
model.is_atking = false;
if (view.status !== "atk") view.status_change("idle");
}
// 如果只在 Y 轴移动,也要播放 move 动画
if (isChangingLane && view.status !== "move" && view.status !== "atk") {
view.status_change("move");
}
/** 渲染层级重排(双阵营统一处理) */
this.updateRenderOrder();
}
private clearCombatTarget(model: HeroAttrsComp): void {
@@ -182,300 +136,53 @@ export class MoveSystem extends ecs.ComblockSystem implements ecs.ISystemUpdate
return dist <= attackRange;
}
private processCombatLogic(e: ecs.Entity, move: MoveComp, view: HeroViewComp, model: HeroAttrsComp, enemy: HeroViewComp) {
const rangeType = model.type as HType.Melee | HType.Mid | HType.Long;
switch (rangeType) {
case HType.Melee:
this.processMeleeLogic(e, move, view, model, enemy);
break;
case HType.Mid:
this.processMidLogic(e, move, view, model, enemy);
break;
case HType.Long:
this.processLongLogic(e, move, view, model, enemy);
break;
default:
this.processMidLogic(e, move, view, model, enemy); // 默认中程
break;
}
}
/**
* 近战交战怪物进入警戒线ALERT_RANGE_X内才向右推进迎敌
* 贴近到固定接触距离后停下攻击;警戒线外则退回后方编队位待命
* Why: 近战手短需主动逼近,但无威胁时不应孤军前压暴露阵型;
* 停止判定用固定接触距离(非攻击距离 dis
* 使英雄进入攻击范围后仍继续贴近,直到贴上目标才站稳输出。
* 怪物 y 与英雄同在 GAME_LINE 附近(微调也在攻击范围内),无需管 Y 轴。
* 战斗位移:向最近敌人推进,进入攻击范围后停止。
* 推进无上限限制,但击退/位移受阵营后退边界保护
*/
private processMeleeLogic(e: ecs.Entity, move: MoveComp, view: HeroViewComp, model: HeroAttrsComp, enemy: HeroViewComp) {
if (!enemy?.node) {
this.processFormationCombat(e, move, view, model);
return;
}
private processCombatLogic(_e: ecs.Entity, move: MoveComp, view: HeroViewComp, model: HeroAttrsComp, enemy: HeroViewComp) {
const selfX = view.node.position.x;
const enemyX = enemy.node.position.x;
const dist = Math.abs(selfX - enemyX);
// 已贴近到固定接触距离:停下攻击
if (dist <= MoveSystem.MELEE_ENGAGE_X) {
const attackRange = model.dis;
const inRange = dist <= attackRange;
if (inRange) {
/** 进入攻击范围:停止移动,进入攻击状态,由 SCastSystem 负责技能释放 */
model.is_atking = true;
if (view.status !== "atk") view.status_change("idle");
return;
}
// 怪物在警戒线之外:不主动迎敌,退回后方编队位待命
if (dist > MoveSystem.MELEE_ALERT_X) {
this.clearCombatTarget(model);
this.moveToSlot(view, move, model, move.targetX);
const dir = enemyX > selfX ? 1 : -1;
view.scale = dir;
if (view.status === "move") {
view.status_change("idle");
}
} else {
/** 未接触:向敌人方向推进 */
model.is_atking = false;
return;
const dir = enemyX > selfX ? 1 : -1;
move.direction = dir;
const speed = model.speed / 3;
const delta = speed * this.dt * dir;
let newX = selfX + delta;
/** 后退边界钳制:防止被击退/位移推出屏幕 */
newX = this.clampByFacBoundary(newX, model.fac);
if (Math.abs(newX - selfX) >= 0.01) {
view.node.setPosition(newX, view.node.position.y, 0);
view.status_change("move");
}
}
// 警戒线内未贴近:向怪物方向推进(攻击范围内也继续移动),于接触距离处停下
const dir = enemyX > selfX ? 1 : -1;
move.direction = dir;
// 终止点取接触距离与推进上限的较小者,防止越过 150 继续追击
let stopX = Math.min(enemyX - dir * MoveSystem.MELEE_ENGAGE_X, MoveSystem.HERO_ADVANCE_LIMIT_X);
// 同阵营最小间距约束:防止近战推进时穿越前方队友
stopX = this.clampAllySpacing(e, view, move, model, stopX);
const speed = model.speed / 3;
this.moveEntity(view, dir, speed, stopX);
model.is_atking = false;
}
private processMidLogic(e: ecs.Entity, move: MoveComp, view: HeroViewComp, model: HeroAttrsComp, enemy: HeroViewComp) {
this.processFormationCombat(e, move, view, model);
}
private processLongLogic(e: ecs.Entity, move: MoveComp, view: HeroViewComp, model: HeroAttrsComp, enemy: HeroViewComp) {
this.processFormationCombat(e, move, view, model);
}
private processFormationCombat(e: ecs.Entity, move: MoveComp, view: HeroViewComp, model: HeroAttrsComp) {
this.moveToSlot(view, move, model, move.targetX);
model.is_atking = true;
}
private getGlobalFormationSlot(self: ecs.Entity, model: HeroAttrsComp): { targetX: number, targetY: number } {
const allAllies: ecs.Entity[] = [];
ecs.query(this.getHeroMoveMatcher()).forEach(e => {
const attrs = e.get(HeroAttrsComp);
const view = e.get(HeroViewComp);
const move = e.get(MoveComp);
if (!attrs || !view?.node || !move) return;
if (attrs.is_dead || attrs.is_reviving) return;
if (attrs.fac !== model.fac) return;
allAllies.push(e);
});
allAllies.sort((a, b) => {
const attrsA = a.get(HeroAttrsComp);
const attrsB = b.get(HeroAttrsComp);
const priorityA = attrsA ? this.getFormationPriority(attrsA) : 0;
const priorityB = attrsB ? this.getFormationPriority(attrsB) : 0;
if (priorityA !== priorityB) return priorityB - priorityA;
const lvA = attrsA?.lv ?? 1;
const lvB = attrsB?.lv ?? 1;
if (lvA !== lvB) return lvB - lvA;
const moveA = a.get(MoveComp);
const moveB = b.get(MoveComp);
const orderA = moveA?.spawnOrder ?? 0;
const orderB = moveB?.spawnOrder ?? 0;
if (orderA !== orderB) return orderA - orderB;
return a.eid - b.eid;
});
// allAllies 按优先级降序(索引 0 = 最高优先级 = 最靠右/最先接敌),
// 反转为"从左侧起的排位"后再分配 X最高优先级 → 最右
const priorityRank = Math.max(0, allAllies.findIndex(entity => entity === self));
const rankFromLeft = allAllies.length - 1 - priorityRank;
const targetX = this.calcSlotX(rankFromLeft, allAllies.length);
return { targetX, targetY: BoxSet.GAME_LINE };
}
// ==================== 编队站位(方案 A单行横向分布 ====================
/** 编队左端起点(最靠后/最后接敌) */
private static readonly FORMATION_LEFT_X = -320;
/** 编队右端终点(最靠前/最先接敌) */
private static readonly FORMATION_RIGHT_X = 100;
/** 固定步进间距(人数较少时按此从左侧逐位右排) */
private static readonly FORMATION_STEP_X = 60;
/** 近战贴近怪物的固定接触距离:小于攻击距离 dis使英雄进攻击范围后仍继续贴近到此间距才停 */
private static readonly MELEE_ENGAGE_X = 60;
/** 近战警戒线:怪物进入该距离内才主动向右推进迎敌,否则退回后方编队位待命 */
private static readonly MELEE_ALERT_X = 200;
/** 英雄向敌人推进的最远终止点 X防止近战追敌时无限右移脱离战场 */
private static readonly HERO_ADVANCE_LIMIT_X = 100;
/** 同阵营英雄间最小间距:小于该值时低优先级一方需让位 */
private static readonly ALLY_MIN_GAP_X = 30;
/**
* 计算某个槽位的目标 X。
* Why: 单行站位避免横版拥挤——人少时按 60 固定步进从 -320 起步逐位右排(保持松散),
* 人多到 60 步进装不下时,改为在 [-320, 100] 区间均分(利用全宽)。
* @param rankFromLeft 从左侧起的排位0=最靠左/最后,越大越靠右/越先接敌)
* @param total 当前存活英雄总数
*/
private calcSlotX(rankFromLeft: number, total: number): number {
const left = MoveSystem.FORMATION_LEFT_X;
const right = MoveSystem.FORMATION_RIGHT_X;
const step = MoveSystem.FORMATION_STEP_X;
if (total <= 1) return left;
// 固定步进能容纳的最大人数:相邻 60且最右不超过 right
const maxStepCount = Math.floor((right - left) / step) + 1;
if (total <= maxStepCount) {
return left + rankFromLeft * step;
}
// 超出后均分整段区间(含左右端点)
return left + (right - left) * (rankFromLeft / (total - 1));
}
private moveToSlot(view: HeroViewComp, move: MoveComp, model: HeroAttrsComp, targetX: number) {
const currentX = view.node.position.x;
const currentY = view.node.position.y;
// 当 X 和 Y 都到达目标时,才算真正到达
if (Math.abs(currentX - targetX) <= 2 && Math.abs(currentY - move.baseY) <= 2) {
view.node.setPosition(targetX, move.baseY, 0);
view.status_change("idle");
return;
}
const dir = targetX > currentX ? 1 : -1;
move.direction = dir;
const speed = model.speed / 3;
// 同阵营最小间距约束:防止编队挤压时穿越前方队友
const clampedTargetX = this.clampAllySpacing(view.ent, view, move, model, targetX);
this.moveEntity(view, dir, speed, clampedTargetX);
}
/**
* 阵营横向最小间距约束
* Why: 编队/追击时英雄可能挤到一起,低优先级一方需要保持在高优先级身后 ALLY_MIN_GAP_X 处,
* 避免视觉重叠与站位混乱。
* 优先级编队优先级高者在前同级时召唤早spawnOrder 小)者在前。
* @returns 裁剪后的目标 X保证不越过身前队友
* 阵营裁剪 X 坐标,防止被击退/位移推出屏幕
* HERO 后退不越过 BoxSet.LETF_ENDMON 后退不越过 BoxSet.RIGHT_END。
*/
private clampAllySpacing(self: ecs.Entity, view: HeroViewComp, selfMove: MoveComp, model: HeroAttrsComp, targetX: number): number {
const selfX = view.node.position.x;
const selfY = view.node.position.y;
let clamped = targetX;
ecs.query(this.getHeroMoveMatcher()).forEach(e => {
if (e === self) return;
const attrs = e.get(HeroAttrsComp);
const v = e.get(HeroViewComp);
const mv = e.get(MoveComp);
if (!attrs || !v?.node || !mv) return;
if (attrs.is_dead || attrs.is_reviving) return;
if (attrs.fac !== model.fac) return;
// 只约束同排Y 差在阈值内)的队友
if (Math.abs(v.node.position.y - selfY) > this.minSpacingY) return;
const otherX = v.node.position.x;
// 判断对方是否在自己前方(以目标方向为前)
const isAhead = clamped > selfX ? otherX > selfX : otherX < selfX;
if (!isAhead) return;
// 判定谁该在前:编队优先级高者在前;同级时 spawnOrder 小者在前
const myPriority = this.getFormationPriority(model);
const otherPriority = this.getFormationPriority(attrs);
let selfIsFront = false;
if (myPriority !== otherPriority) {
selfIsFront = myPriority > otherPriority;
} else {
selfIsFront = selfMove.spawnOrder <= mv.spawnOrder;
}
if (!selfIsFront) {
// 自己靠后:不能越过 otherX - ALLY_MIN_GAP_X朝右或 otherX + ALLY_MIN_GAP_X朝左
const limitX = clamped > selfX ? otherX - MoveSystem.ALLY_MIN_GAP_X : otherX + MoveSystem.ALLY_MIN_GAP_X;
clamped = clamped > selfX ? Math.min(clamped, limitX) : Math.max(clamped, limitX);
}
});
return clamped;
}
private moveEntity(view: HeroViewComp, direction: number, speed: number, stopAtX?: number) {
const model = view.ent.get(HeroAttrsComp);
const move = view.ent.get(MoveComp);
if (!model || !move) return;
/** 按阵营边界裁剪,防止跑出战场可移动区域 */
const cfg = this.facConfigs[model.fac] || this.facConfigs[FacSet.HERO];
const moveMinX = Math.min(cfg.moveBackX, cfg.moveFrontX);
const moveMaxX = Math.max(cfg.moveBackX, cfg.moveFrontX);
const currentX = view.node.position.x;
const currentY = view.node.position.y;
const delta = speed * this.dt * direction;
let newX = view.node.position.x + delta;
if (currentX < moveMinX && direction < 0) {
// X 轴到底了,如果 Y 轴还在换路,继续维持 move 状态
if (Math.abs(currentY - move.baseY) > 2) {
view.status_change("move");
} else {
view.status_change("idle");
}
return;
private clampByFacBoundary(x: number, fac: number): number {
if (fac === FacSet.HERO) {
return Math.max(BoxSet.LETF_END, x);
}
if (currentX > moveMaxX && direction > 0) {
if (Math.abs(currentY - move.baseY) > 2) {
view.status_change("move");
} else {
view.status_change("idle");
}
return;
}
newX = Math.max(moveMinX, Math.min(moveMaxX, newX));
if (stopAtX !== undefined) {
/** 指定停止点时,限制不越过 stopAtX */
newX = direction > 0 ? Math.min(newX, stopAtX) : Math.max(newX, stopAtX);
}
if (Math.abs(newX - currentX) < 0.01) {
// X轴虽然没变化但如果Y轴还在移动依然是move状态
if (Math.abs(currentY - move.baseY) > 2) {
view.status_change("move");
} else {
view.status_change("idle");
}
return;
}
view.node.setPosition(newX, view.node.position.y, 0); // 注意:这里只更新 XY 在外部平滑逻辑更新
view.status_change("move");
}
/**
* 编队站位优先级(数值越大越靠前/越靠右)。
* Why: 阵容职能决定承伤顺序——死亡触发英雄需最先阵亡以触发遗志,
* 其次坦克承伤,再次战士/刺客,远程射手/法师/辅助依次靠后保护。
* 排序档位:死亡触发 > 坦克 > 战士 > 刺客 > 射手 > 法师 > 辅助
*/
private getFormationPriority(model: HeroAttrsComp): number {
// 死亡触发英雄最高优先:需要先死以发动遗志/献祭
if (model.dead && Object.keys(model.dead).length > 0) return 100;
if (model.role !== undefined) {
switch (model.role) {
case HRole.Tank: return 90;
case HRole.Warrior: return 80;
case HRole.Assassin: return 70;
case HRole.Archer: return 60;
case HRole.Mage: return 50;
case HRole.Support: return 40;
}
}
// 兜底:未配置 role 时按攻击定位(近战 > 中程 > 远程)
const rangeType = model.type as HType.Melee | HType.Mid | HType.Long;
if (rangeType === HType.Melee) return 30;
if (rangeType === HType.Mid) return 20;
return 10;
}
private resolveCombatRange(model: HeroAttrsComp, defaultMin: number, defaultMax: number): [number, number] {
const minRange = model.getCachedMinSkillDistance();
const maxRange = model.getCachedMaxSkillDistance();
if (maxRange <= 0) return [defaultMin, defaultMax];
const safeMin = Math.max(0, Math.min(minRange, maxRange - 20));
return [safeMin, maxRange];
return Math.min(BoxSet.RIGHT_END, x);
}
private findNearestEnemy(entity: ecs.Entity): HeroViewComp | null {
@@ -527,20 +234,11 @@ export class MoveSystem extends ecs.ComblockSystem implements ecs.ISystemUpdate
actorView.node.parent = actorRoot;
}
// 获取 spawnOrder由于可能挂载 MoveComp 或 MonMoveComp我们需要动态获取
let spawnOrder = 0;
const heroMove = e.get(MoveComp);
if (heroMove) {
spawnOrder = heroMove.spawnOrder;
} else {
const monMove = e.get(MonMoveComp);
if (monMove) {
spawnOrder = monMove.spawnOrder;
}
}
/** 统一从 MoveComp 获取 spawnOrder */
const move = e.get(MoveComp);
const spawnOrder = move?.spawnOrder ?? 0;
// 按 Y 轴计算渲染层级权重Y 越小,说明越靠近屏幕下方,应该渲染在越前面)
// 之前的 isFly 逻辑已被移除,统一按 Y 轴处理三路渲染
const laneScore = -actorView.node.position.y;
// X 轴权重:站在前排的(交战处)优先渲染
const frontScore = attrs.fac === FacSet.HERO ? actorView.node.position.x : -actorView.node.position.x;