1. 移除英雄出生落点偏移,改为按职业优先级从右向左排队 2. 重构英雄卡点击逻辑,支持点击整张卡牌选取 3. 新增英雄血量背景预制体与UI按钮样式 4. 优化AOE技能目标选取逻辑,使用中排基准点
415 lines
18 KiB
TypeScript
415 lines
18 KiB
TypeScript
/**
|
||
* @file MoveComp.ts
|
||
* @description 统一移动组件与系统(英雄 + 怪物)
|
||
*
|
||
* 核心规则:
|
||
* 1. 准备阶段:不推进不索敌;英雄按职业优先级(坦克→战士→刺客→射手→法师→辅助)
|
||
* 从最右迎敌位向左排队移动,防止同类型落点重叠;怪物原地待命。
|
||
* 2. 战斗阶段:双方向最近敌人推进,进入攻击范围(model.dis)后停止移动并攻击。
|
||
* 3. 场上无敌人:停止移动(战斗结束,由 MissionComp 进入结算)。
|
||
* 4. 击退/位移不会把单位推出屏幕:HERO 不越过 BoxSet.LETF_END,MON 不越过 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.1s:0.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_END;MON 后退不越过 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);
|
||
});
|
||
}
|
||
}
|