Files
pixelheros/assets/script/game/hero/MoveComp.ts
pan a3375ec3b6 refactor(hero): 统一英雄攻击距离默认值并优化队友间距逻辑
1. 将英雄/怪物默认攻击距离调整为近战150、中程300、远程450,通过HeroDisVal统一管理
2. 新增同阵营单位移动和待机时的间距避让逻辑,避免单位重叠穿模,添加自然蠕动效果
2026-08-13 10:23:14 +08:00

374 lines
16 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 { 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;
reset() {
this.direction = 1;
this.targetX = 0;
this.moving = true;
this.baseY = 0;
this.spawnOrder = 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;
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);
}
/** 准备阶段:双方原地待命,不推进不索敌 */
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);
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;
}
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 static readonly ALLY_MIN_GAP_X = 10;
/** 挤位时的蠕动幅度(像素) */
private static readonly JOSTLE_AMPLITUDE = 2;
/**
* 战斗位移:向最近敌人推进,进入攻击范围后停止。
* 推进无上限限制,可穿越队友;停止移动后受同阵营最小间距约束。
*/
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;
const inRange = dist <= attackRange;
if (inRange) {
/** 进入攻击范围:停止移动,进入攻击状态,由 SCastSystem 负责技能释放 */
model.is_atking = true;
const dir = enemyX > selfX ? 1 : -1;
view.scale = dir;
if (view.status === "move") {
view.status_change("idle");
}
/** 停止时若与队友重叠,则向后微调保持最小间距,并加入蠕动避免死板 */
this.adjustSpacingWhenIdle(_e, view, move, model);
} 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.adjustSpacingWhileMoving(_e, view, move, model, newX, dir);
/** 后退边界钳制:防止被击退/位移推出屏幕 */
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");
}
}
}
/**
* 推进时的间距避让。
* Why: 允许穿越队友,但若与队友完全同点(重叠),后来者稍停一拍,
* 避免视觉上的"穿模"过于生硬,营造"挤过去"的自然感。
* @returns 调整后的目标 X被阻挡时返回 selfX即本帧不移动
*/
private adjustSpacingWhileMoving(self: ecs.Entity, view: HeroViewComp, selfMove: MoveComp, model: HeroAttrsComp, targetX: number, _dir: number): number {
const selfX = view.node.position.x;
const selfY = view.node.position.y;
let blocked = false;
ecs.query(this.getHeroViewMatcher()).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;
if (Math.abs(v.node.position.y - selfY) > 30) return;
const otherX = v.node.position.x;
/** 同点判定:间距小于最小间距的一半视为完全重叠 */
if (Math.abs(selfX - otherX) < MoveSystem.ALLY_MIN_GAP_X * 0.5) {
/** spawnOrder 大者(后来者)被阻挡 */
if (selfMove.spawnOrder > mv.spawnOrder) {
blocked = true;
}
}
});
return blocked ? selfX : targetX;
}
/**
* 停止移动时的间距微调 + 蠕动效果。
* Why: 允许推进时穿越队友,但进入攻击范围停下后若与队友重叠,
* 则向后微调保持 ALLY_MIN_GAP_X 间距,避免视觉完全重叠。
* 同时加入基于时间的随机蠕动,营造"挤到前方攻击"的生动感。
* Y 轴不变,仅约束 X。
* 优先级spawnOrder 小者(先出生)不动,大者向后让位。
*/
private adjustSpacingWhenIdle(self: ecs.Entity, view: HeroViewComp, selfMove: MoveComp, model: HeroAttrsComp): void {
const selfX = view.node.position.x;
const selfY = view.node.position.y;
let adjustX = selfX;
let isCrowded = false;
ecs.query(this.getHeroViewMatcher()).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) > 30) return;
const otherX = v.node.position.x;
const gap = Math.abs(adjustX - otherX);
if (gap >= MoveSystem.ALLY_MIN_GAP_X) return;
isCrowded = true;
/** spawnOrder 小者不动,大者向后让位 */
if (selfMove.spawnOrder > mv.spawnOrder) {
/** 自己靠后:向远离对方的方向后退到最小间距处 */
const dir = adjustX >= otherX ? 1 : -1;
adjustX = otherX + dir * MoveSystem.ALLY_MIN_GAP_X;
}
});
/** 拥挤时加入蠕动:基于时间+出生序的伪随机偏移,避免多个单位同步抖动 */
if (isCrowded) {
const time = Date.now() / 1000;
const jostlePhase = time * 3 + selfMove.spawnOrder * 0.7; // 不同单位错开相位
const jostleOffset = Math.sin(jostlePhase) * MoveSystem.JOSTLE_AMPLITUDE;
adjustX += jostleOffset;
}
if (Math.abs(adjustX - selfX) >= 0.01) {
/** 后退边界钳制 */
adjustX = this.clampByFacBoundary(adjustX, model.fac);
view.node.setPosition(adjustX, view.node.position.y, 0);
}
}
/**
* 按阵营裁剪 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 actorRoot = scene?.entityLayer?.node?.getChildByName("HERO");
if (!actorRoot) return;
const now = Date.now() / 1000;
if (now - this.lastRenderSortAt < this.renderSortInterval) return;
this.lastRenderSortAt = now;
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;
if (actorView.node.parent !== actorRoot) {
actorView.node.parent = actorRoot;
}
/** 统一从 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.bossPriority = attrs.is_boss ? 1 : 0;
entry.laneScore = laneScore;
entry.frontScore = frontScore;
entry.spawnOrder = spawnOrder;
entry.eid = e.eid;
this.renderEntryCount += 1;
});
this.renderEntries.length = this.renderEntryCount;
/** 定时重排同层节点Boss 优先级 -> Y 轴路次 -> 前后位置 -> 出生序 -> eid */
this.renderEntries.sort((a, b) => {
if (a.bossPriority !== b.bossPriority) return a.bossPriority - b.bossPriority;
// 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;
});
this.renderEntries.forEach((item, index) => {
item.node.setSiblingIndex(index);
});
}
}