Files
pixelheros/assets/script/game/hero/MoveComp.ts
panFD 63a9f6cba5 feat(hero): 新增英雄详情弹窗与图鉴页面重构
1.  新增HeroInfoComp英雄详情弹窗,支持英雄属性展示、升级操作、前后切换功能
2.  重构HerosListComp图鉴页面,将详情面板逻辑剥离至弹窗,点击卡片直接打开详情页
3.  新增英雄列表排序工具函数,统一图鉴与弹窗切换的排序逻辑
4.  优化战斗渲染排序逻辑,移除无关的Y轴渲染权重计算
5.  新增英雄详情弹窗UI配置与预制体资源
2026-09-18 11:22:03 +08:00

492 lines
21 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_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, FightSet } 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 {
/**
* 节间过场移动动画锁(全局):
* 置位期间,存活英雄即使已归位到编队槽位也保持"move"移动动画(配合地图滚动,表现行军推进)。
* 由 MissionComp 在节间过场开始(背景滚动启动)时置 true、过场结束(背景滚动停止)时置 false。
*/
public static transitionMoveLock = false;
/** 渲染层级重排节流,避免每帧排序 */
private readonly renderSortInterval = 0.05;
private lastRenderSortAt = 0;
private heroViewMatcher: ecs.IMatcher | null = null;
private readonly renderEntries: { node: Node; spawnOrder: number; eid: number; isCastingMax: boolean }[] = [];
private renderEntryCount = 0;
// ======================== 英雄编队排队 ========================
/**
* 三排编队锚点 x(单一数据源取自 FightSet,与 MissionHeroComp 的 HERO_ROW_FRONT/MID/BACK 一致):
* 前排 / 中排 / 后排。准备阶段按"职业优先级名次"映射到三排(见 rebuildHeroQueueSlots)。
*/
private static readonly ROW_FRONT_X = FightSet.HERO_ROW_FRONT_X;
private static readonly ROW_MID_X = FightSet.HERO_ROW_MID_X;
private static readonly ROW_BACK_X = FightSet.HERO_ROW_BACK_X;
/** 编队排队允许向左扩展到的最小 x(后排自锚点向左排开时的下限) */
private static readonly QUEUE_LEFT_LIMIT = FightSet.HERO_ROW_BACK_X - 120;
/** 编队槽位默认间隔(像素);后排人数过多时自动压缩间距 */
private static readonly QUEUE_SPACING = 30;
/** 编队归位目标时长(秒):任意距离动态提速,保证约 1s 到位 */
private static readonly QUEUE_ARRIVE_TIME = 1;
/** 编队槽位重算节流间隔(秒):召唤/死亡后最多延迟一次重算 */
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 {
this.processMonQueue(move, view, model);
}
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;
view.status_change("idle");
}
/** 渲染层级重排(双阵营统一处理) */
this.updateRenderOrder();
}
private clearCombatTarget(model: HeroAttrsComp): void {
model.combat_target_eid = -1;
model.enemy_in_cast_range = false;
}
// ======================== 英雄编队排队(准备阶段) ========================
/**
* 英雄编队排队:
* 按 坦克→战士→刺客→射手→法师→辅助 的职业优先级(即 HRole 枚举值升序)做全局排序,
* 再按名次映射三排锚点:第 1 名→前排、第 2 名→中排、第 3 名起→后排向左排开。
* 槽位互不重叠,保证一个位置仅一个英雄。
*
* @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 (MoveSystem.transitionMoveLock) {
view.status_change("move");
} else {
view.status_change("idle");
}
return;
}
/** 未到位:向槽位推进,朝移动方向转身 */
const dir = targetX > selfX ? 1 : -1;
move.direction = dir;
view.scale = dir;
/** 编队排队允许突破战斗左边界(LETF_END=后排锚点),扩展至 QUEUE_LEFT_LIMIT */
const queueClamp = (x: number): number => Math.max(MoveSystem.QUEUE_LEFT_LIMIT, x);
/** 归位速度:按剩余距离动态计算,保证任意距离约 1s 到位;不低于基础移速,避免近距离僵住 */
const dist = Math.abs(targetX - selfX);
const speed = Math.max(model.speed / 3, dist / MoveSystem.QUEUE_ARRIVE_TIME);
const delta = speed * this.dt * dir;
let newX = queueClamp(selfX + delta);
/** 防过冲:本帧会越过目标则直接贴齐 */
if ((dir > 0 && newX > targetX) || (dir < 0 && newX < targetX)) {
newX = targetX;
}
if (Math.abs(newX - selfX) >= 0.01) {
view.node.setPosition(newX, view.node.position.y, 0);
view.status_change("move");
}
}
// ======================== 怪物入场归位(准备阶段) ========================
/**
* 怪物入场归位:从屏幕右边缘向 move.targetX(目标排锚点)移动,到位后 idle。
* 与英雄 processHeroQueue 对称,但怪物目标位在出生时由 MissionMonComp 一次性写入 targetX,无需动态重算槽位。
* 到位容差 2px,与英雄一致。
*/
private processMonQueue(move: MoveComp, view: HeroViewComp, model: HeroAttrsComp): void {
const targetX = move.targetX;
const selfX = view.node.position.x;
if (Math.abs(targetX - selfX) <= 2) {
if (Math.abs(selfX - targetX) > 0.01) {
view.node.setPosition(targetX, view.node.position.y, 0);
}
view.scale = -1;
view.status_change("idle");
return;
}
const dir = targetX > selfX ? 1 : -1;
move.direction = dir;
view.scale = dir;
const dist = Math.abs(targetX - selfX);
const speed = Math.max(model.speed / 3, dist / MoveSystem.QUEUE_ARRIVE_TIME);
const delta = speed * this.dt * dir;
let newX = selfX + delta;
if ((dir > 0 && newX > targetX) || (dir < 0 && newX < targetX)) {
newX = targetX;
}
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;
}
/**
* 重算编队槽位:
* 收集全部存活英雄,按 职业优先级(HRole 升序)→ 出生序 → eid 稳定排序得到全局名次 rank,
* 再按名次映射三排锚点:
* - rank 0(最优先)→ 前排 ROW_FRONT_X
* - rank 1 → 中排 ROW_MID_X
* - rank ≥ 2 → 后排,自 ROW_BACK_X 起按 (rank-1) 个间隔向左依次排开
* 效果:高优先级职业永远占据更靠前的排;当更高优先级英雄插入时,低优先级英雄自动向后让位
* (如 近战+射手+近战 → 两近战分占前/中排,射手被挤到后排)。
* 后排人数过多时压缩间距,下限不越过 QUEUE_LEFT_LIMIT。
*/
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)
);
/** 后排人数 = 总名次从 rank2 起的数量,用于预压缩间距 */
const backCount = Math.max(0, heroes.length - 2);
const backMaxWidth = MoveSystem.ROW_BACK_X - MoveSystem.QUEUE_LEFT_LIMIT;
const backSpacing = backCount > 1
? Math.min(MoveSystem.QUEUE_SPACING, backMaxWidth / (backCount - 1))
: MoveSystem.QUEUE_SPACING;
this.queueSlots.length = 0;
for (let rank = 0; rank < heroes.length; rank++) {
let targetX: number;
if (rank === 0) {
targetX = MoveSystem.ROW_FRONT_X;
} else if (rank === 1) {
targetX = MoveSystem.ROW_MID_X;
} else {
/** 后排:rank2 在锚点,rank3 起向左排开 */
targetX = MoveSystem.ROW_BACK_X - (rank - 2) * backSpacing;
}
this.queueSlots.push({ eid: heroes[rank].eid, targetX });
}
}
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");
/** 分层重排同层节点:层内层级只由「是否释放大招」决定,出生序/eid 仅保证排序稳定
* (Boss 已有独立 BOSS 层固定在最底,天然在最下层) */
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;
const entryIndex = this.renderEntryCount;
let entry = this.renderEntries[entryIndex];
if (!entry) {
entry = { node: actorView.node, spawnOrder: 0, eid: 0, isCastingMax: false };
this.renderEntries.push(entry);
}
entry.node = actorView.node;
entry.spawnOrder = spawnOrder;
entry.eid = e.eid;
entry.isCastingMax = actorView.isCastingMax;
this.renderEntryCount += 1;
});
this.renderEntries.length = this.renderEntryCount;
this.renderEntries.sort((a, b) => {
// 层级只受大招影响:正在播放大招的单位排在最后,siblingIndex 越大渲染层级越靠上
if (a.isCastingMax !== b.isCastingMax) return a.isCastingMax ? 1 : -1;
// 与位置无关的稳定排序:出生序 -> eid
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);
});
}
}