Files
pixelheros/assets/script/game/hero/MoveComp.ts
panFD 6c82c632b6 feat: 重构英雄编队逻辑,优化出生与排队规则
1. 移除英雄出生落点偏移,改为按职业优先级从右向左排队
2. 重构英雄卡点击逻辑,支持点击整张卡牌选取
3. 新增英雄血量背景预制体与UI按钮样式
4. 优化AOE技能目标选取逻辑,使用中排基准点
2026-08-19 22:52:17 +08:00

415 lines
18 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 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 { HRole } from "../common/config/heroSet";
import { Node } from "cc";
@ecs.register('MoveComp')
export class MoveComp extends ecs.Comp {
/** 朝向1=向右HERO-1=向左MON由系统根据 fac 自动维护 */
direction: number = 1;
/** 当前移动目标 X */
targetX: number = 0;
/** 是否允许移动(出生落地前会短暂关闭) */
moving: boolean = true;
/** 站位基准 Y移动时强制贴地 */
baseY: number = 0;
/** 出生序,用于同条件渲染排序稳定 */
spawnOrder: number = 0;
/** 击退速度(像素/秒),受击瞬间由 HeroAtkSystem 赋值MoveSystem 每帧指数衰减至 0 */
knockbackVelocity: number = 0;
reset() {
this.direction = 1;
this.targetX = 0;
this.moving = true;
this.baseY = 0;
this.spawnOrder = 0;
this.knockbackVelocity = 0;
}
}
@ecs.register('MoveSystem')
export class MoveSystem extends ecs.ComblockSystem implements ecs.ISystemUpdate {
/** 渲染层级重排节流,避免每帧排序 */
private readonly renderSortInterval = 0.05;
private lastRenderSortAt = 0;
private heroViewMatcher: ecs.IMatcher | null = null;
private readonly renderEntries: { node: Node; bossPriority: number; frontScore: number; spawnOrder: number; eid: number; laneScore: number }[] = [];
private renderEntryCount = 0;
// ======================== 英雄编队排队 ========================
/** 编队锚点:最右迎敌位(与 MissionHeroComp.HERO_ROW_FRONT 的 x 对齐,避免循环依赖在此独立定义) */
private static readonly QUEUE_ANCHOR_X = -200;
/** 编队槽位默认间隔(像素);人数超出边界宽度时自动压缩间距 */
private static readonly QUEUE_SPACING = 30;
/** 编队槽位重算节流间隔(秒):召唤/死亡后最多延迟一次重算 */
private readonly queueRebuildInterval = 0.1;
private lastQueueRebuildAt = 0;
/** 编队槽位缓存:按职业优先级排序后各英雄的目标 X */
private readonly queueSlots: { eid: number; targetX: number }[] = [];
private getHeroViewMatcher(): ecs.IMatcher {
if (!this.heroViewMatcher) {
this.heroViewMatcher = ecs.allOf(HeroAttrsComp, HeroViewComp);
}
return this.heroViewMatcher;
}
filter(): ecs.IMatcher {
return ecs.allOf(MoveComp, HeroViewComp, HeroAttrsComp);
}
update(e: ecs.Entity) {
/** 战斗未开始/暂停时不驱动移动 */
if (!smc.mission.play || smc.mission.pause) return;
const model = e.get(HeroAttrsComp);
const move = e.get(MoveComp);
const view = e.get(HeroViewComp);
if (!model || !move || !view || !view.node) return;
if (!move.moving) return;
/** 关卡阶段性冻结怪物行为 */
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;
}
/** 所有移动都锁定在 baseY避免出现"漂移" */
if (view.node.position.y !== move.baseY) {
view.node.setPosition(view.node.position.x, move.baseY, 0);
}
/** 击退位移:每帧叠加 knockbackVelocity 并指数衰减,与推进解耦 */
if (Math.abs(move.knockbackVelocity) > 0.01) {
const newX = view.node.position.x + move.knockbackVelocity * this.dt;
const clampedX = this.clampByFacBoundary(newX, model.fac);
if (Math.abs(clampedX - view.node.position.x) > 0.01) {
view.node.setPosition(clampedX, view.node.position.y, 0);
}
// 时间常数 τ=0.1s0.1 秒衰减到约 37%0.3 秒衰减到约 5%,总位移 ≈ v0 * 0.0632
move.knockbackVelocity *= Math.exp(-10 * this.dt);
if (Math.abs(move.knockbackVelocity) < 0.01) {
move.knockbackVelocity = 0;
}
}
/** 准备阶段:不推进不索敌;英雄向编队槽位排队移动,怪物原地待命 */
if (!smc.mission.in_fight) {
this.clearCombatTarget(model);
model.is_atking = false;
if (model.fac === FacSet.HERO) {
this.processHeroQueue(e, model, move, view);
} else 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);
model.is_atking = false;
if (view.status !== "atk") view.status_change("idle");
}
/** 渲染层级重排(双阵营统一处理) */
this.updateRenderOrder();
}
private clearCombatTarget(model: HeroAttrsComp): void {
model.combat_target_eid = -1;
model.enemy_in_cast_range = false;
}
// ======================== 英雄编队排队(准备阶段) ========================
/**
* 英雄编队排队:
* 按 坦克→战士→刺客→射手→法师→辅助 的职业优先级(即 HRole 枚举值升序),
* 从最右迎敌位QUEUE_ANCHOR_X向左依次排开防止同类型英雄落点重叠。
*
* @param entity 当前英雄实体
* @param model 英雄属性(取 speed / role
* @param move 移动组件(维护 direction
* @param view 视图组件(驱动位移与动画状态)
*/
private processHeroQueue(entity: ecs.Entity, model: HeroAttrsComp, move: MoveComp, view: HeroViewComp): void {
const targetX = this.resolveHeroQueueTargetX(entity);
const selfX = view.node.position.x;
/** 无槽位(未登记)或已到位:贴齐槽位并原地待命,恢复朝右迎敌 */
if (targetX === null || Math.abs(targetX - selfX) <= 2) {
if (targetX !== null && Math.abs(selfX - targetX) > 0.01) {
view.node.setPosition(targetX, view.node.position.y, 0);
}
view.scale = 1;
if (view.status !== "atk") view.status_change("idle");
return;
}
/** 未到位:向槽位推进(与战斗推进同速),朝移动方向转身 */
const dir = targetX > selfX ? 1 : -1;
move.direction = dir;
view.scale = dir;
const delta = (model.speed / 3) * this.dt * dir;
const newX = this.clampByFacBoundary(selfX + delta, model.fac);
if (Math.abs(newX - selfX) >= 0.01) {
view.node.setPosition(newX, view.node.position.y, 0);
view.status_change("move");
}
}
/** 查询当前英雄的编队槽位目标 X无槽位返回 null */
private resolveHeroQueueTargetX(entity: ecs.Entity): number | null {
const now = Date.now() / 1000;
if (now - this.lastQueueRebuildAt >= this.queueRebuildInterval) {
this.lastQueueRebuildAt = now;
this.rebuildHeroQueueSlots();
}
for (const slot of this.queueSlots) {
if (slot.eid === entity.eid) return slot.targetX;
}
return null;
}
/**
* 重算编队槽位:
* 收集全部存活英雄,按 职业优先级 → 出生序 → eid 稳定排序后,
* 从 QUEUE_ANCHOR_X 向左按间隔分配;人数过多时压缩间距,保证不越过 LETF_END。
*/
private rebuildHeroQueueSlots(): void {
const heroes: { eid: number; role: number; spawnOrder: number }[] = [];
ecs.query(this.getHeroViewMatcher()).forEach(e => {
const m = e.get(HeroAttrsComp);
if (!m || m.fac !== FacSet.HERO || m.is_dead) return;
const v = e.get(HeroViewComp);
if (!v?.node) return;
const move = e.get(MoveComp);
heroes.push({ eid: e.eid, role: m.role ?? HRole.Support, spawnOrder: move?.spawnOrder ?? 0 });
});
heroes.sort((a, b) =>
(a.role - b.role) ||
(a.spawnOrder - b.spawnOrder) ||
(a.eid - b.eid)
);
const count = heroes.length;
const maxWidth = MoveSystem.QUEUE_ANCHOR_X - BoxSet.LETF_END;
const spacing = count > 1
? Math.min(MoveSystem.QUEUE_SPACING, maxWidth / (count - 1))
: 0;
this.queueSlots.length = 0;
for (let i = 0; i < count; i++) {
this.queueSlots.push({ eid: heroes[i].eid, targetX: MoveSystem.QUEUE_ANCHOR_X - i * spacing });
}
}
private syncCombatTarget(model: HeroAttrsComp, selfView: HeroViewComp, enemyView: HeroViewComp): void {
if (!enemyView || !enemyView.node || !enemyView.ent) {
this.clearCombatTarget(model);
return;
}
const enemyAttrs = enemyView.ent.get(HeroAttrsComp);
if (!enemyAttrs || enemyAttrs.is_dead || enemyAttrs.is_reviving || enemyAttrs.fac === model.fac) {
this.clearCombatTarget(model);
return;
}
model.combat_target_eid = enemyView.ent.eid;
model.enemy_in_cast_range = this.isEnemyInAttackRange(model, selfView.node.position.x, enemyView.node.position.x);
}
private isEnemyInAttackRange(model: HeroAttrsComp, selfX: number, enemyX: number): boolean {
const dist = Math.abs(selfX - enemyX);
const attackRange = model.dis;
return dist <= attackRange;
}
/**
* 战斗位移:向最近敌人推进,进入攻击范围后停止。
* 推进无上限限制,但击退/位移受阵营后退边界保护。
*/
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);
const attackRange = model.dis;
/** 近战贴身作战:攻击距离 dis(100) 大于技能范围(60),推进到与敌人相隔 60 才停下,保证技能可命中且不过度贴身 */
const stopRange = model.type === 0 ? 60 : attackRange;
const inRange = dist <= stopRange;
if (inRange) {
/** 进入攻击范围:停止移动,进入攻击状态,由 SCastSystem 负责技能释放 */
model.is_atking = true;
const dir = enemyX > selfX ? 1 : -1;
view.scale = dir;
if (view.status === "move") {
view.status_change("idle");
}
} else {
/** 未接触:向敌人方向推进 */
model.is_atking = false;
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");
}
}
}
/**
* 按阵营裁剪 X 坐标,防止被击退/位移推出屏幕。
* HERO 后退不越过 BoxSet.LETF_ENDMON 后退不越过 BoxSet.RIGHT_END。
*/
private clampByFacBoundary(x: number, fac: number): number {
if (fac === FacSet.HERO) {
return Math.max(BoxSet.LETF_END, x);
}
return Math.min(BoxSet.RIGHT_END, x);
}
private findNearestEnemy(entity: ecs.Entity): HeroViewComp | null {
const currentView = entity.get(HeroViewComp);
if (!currentView?.node) return null;
const currentPos = currentView.node.position;
const myFac = entity.get(HeroAttrsComp).fac;
let nearest: HeroViewComp | null = null;
let minDis = Infinity;
/** 遍历筛出最近敌人:以 X 轴距离为主Y 轴距离作为同排的决胜权重,使角色优先攻击同路的敌人 */
ecs.query(this.getHeroViewMatcher()).forEach(e => {
const m = e.get(HeroAttrsComp);
if (m.fac !== myFac && !m.is_dead) {
const v = e.get(HeroViewComp);
if (v?.node) {
const dx = Math.abs(currentPos.x - v.node.position.x);
const dy = Math.abs(currentPos.y - v.node.position.y);
const d = dx + dy * 0.1; // Y轴权重较小仅在 X 相近时起决定作用
if (d < minDis) {
minDis = d;
nearest = v;
}
}
}
});
return nearest;
}
private updateRenderOrder() {
const scene = smc.map?.MapView?.scene;
const layerNode = scene?.entityLayer?.node;
if (!layerNode) return;
const now = Date.now() / 1000;
if (now - this.lastRenderSortAt < this.renderSortInterval) return;
this.lastRenderSortAt = now;
const heroRoot = layerNode.getChildByName("HERO");
const monRoot = layerNode.getChildByName("MON");
const bossRoot = layerNode.getChildByName("BOSS");
/** 分层重排同层节点:各阵营层内按 Y 轴路次 -> 前后位置 -> 出生序 -> eid 排序
* Boss 已有独立 BOSS 层固定在最底,无需 bossPriority 参与排序) */
this.renderEntryCount = 0;
ecs.query(this.getHeroViewMatcher()).forEach(e => {
const attrs = e.get(HeroAttrsComp);
const actorView = e.get(HeroViewComp);
if (!attrs || !actorView?.node || attrs.is_dead) return;
if (attrs.fac !== FacSet.HERO && attrs.fac !== FacSet.MON) return;
/** 按阵营/身份归位到对应显示层 */
let targetRoot: Node | null | undefined;
if (attrs.fac === FacSet.HERO) {
targetRoot = heroRoot;
} else {
targetRoot = attrs.is_boss ? (bossRoot ?? monRoot ?? heroRoot) : (monRoot ?? heroRoot);
}
if (!targetRoot) return;
if (actorView.node.parent !== targetRoot) {
actorView.node.parent = targetRoot;
}
/** 统一从 MoveComp 获取 spawnOrder */
const move = e.get(MoveComp);
const spawnOrder = move?.spawnOrder ?? 0;
// 按 Y 轴计算渲染层级权重Y 越小,说明越靠近屏幕下方,应该渲染在越前面)
const laneScore = -actorView.node.position.y;
// X 轴权重:站在前排的(交战处)优先渲染
const frontScore = attrs.fac === FacSet.HERO ? actorView.node.position.x : -actorView.node.position.x;
const entryIndex = this.renderEntryCount;
let entry = this.renderEntries[entryIndex];
if (!entry) {
entry = { node: actorView.node, bossPriority: 0, frontScore: 0, spawnOrder: 0, eid: 0, laneScore: 0 };
this.renderEntries.push(entry);
}
entry.node = actorView.node;
entry.laneScore = laneScore;
entry.frontScore = frontScore;
entry.spawnOrder = spawnOrder;
entry.eid = e.eid;
this.renderEntryCount += 1;
});
this.renderEntries.length = this.renderEntryCount;
this.renderEntries.sort((a, b) => {
// Y轴靠下的laneScore 较大)应该在后面,从而渲染在上面
if (Math.abs(a.laneScore - b.laneScore) > 10) return a.laneScore - b.laneScore;
if (a.frontScore !== b.frontScore) return a.frontScore - b.frontScore;
if (a.spawnOrder !== b.spawnOrder) return a.spawnOrder - b.spawnOrder;
return a.eid - b.eid;
});
/** 分层应用 siblingIndex各层内部从 0 递增 */
const layerCounters = new Map<Node, number>();
this.renderEntries.forEach((item) => {
const parent = item.node.parent;
if (!parent) return;
const index = layerCounters.get(parent) ?? 0;
item.node.setSiblingIndex(index);
layerCounters.set(parent, index + 1);
});
}
}