You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
911 lines
37 KiB
911 lines
37 KiB
import type { World, EntityId } from 'db://assets/mx/module/ecs';
|
|
import type { AbilityContext } from './AbilityContext';
|
|
import type { TargetSpec } from '../config/BattleConfigTypes';
|
|
import {
|
|
AttrId, TransformComp, TeamComp, HealthComp, TagComp, MovementComp, GridOccupancyComp, UnitMetaComp,
|
|
isYanSoulClaimed,
|
|
isBajiaoBlowRetreating,
|
|
} from '../ecs/Components';
|
|
import { EntityTag } from '../ecs/EntityTag';
|
|
import { BattleTeamId } from '../ecs/BattleTeamId';
|
|
import type { AttributeSystem } from './AttributeSystem';
|
|
import type { SpatialGrid } from '../battlefield/SpatialGrid';
|
|
import type { GridMap } from '../battlefield/GridMap';
|
|
import type { Cell } from '../battlefield/Cell';
|
|
import { applyTargetStageStrategy, type TargetStageStrategyOptions } from './TargetStageStrategy';
|
|
import { BattleMapSpec } from 'db://assets/script/battle/map/BattleMapSpec';
|
|
import { parseLegacyMonsterId, parseMonsterUnitDefId } from 'db://assets/script/battle/config/MonsterHpCalculator';
|
|
import { isHeroWeaponTarget, isMonsterBasicHeroTarget, isMonsterDefaultEnemyTarget, isPeachTreeTower } from 'db://assets/script/battle/config/BattleHeroTable';
|
|
import { MonsterDataManager } from 'db://assets/script/manager/MonsterDataManager';
|
|
|
|
/**
|
|
* 选敌解析:射程与 UI 黄圈同一套——配表像素 / BattleMapSpec.CELL_SIZE = 格。
|
|
* 塔圆心=占地格心均值;行军怪圆心=当前格(可加格内插值)。目标同当前格,不把体盒折进射程。
|
|
* 打敌方:优先离目标营地最近(寻路剩余最短)。
|
|
*/
|
|
export class TargetResolver {
|
|
/** Densest 选敌 scratch,避免每次 skill cast 分配 Map/Array */
|
|
private _densestParent: number[] = [];
|
|
private _densestCount: number[] = [];
|
|
private _densestSumX: number[] = [];
|
|
private _densestSumY: number[] = [];
|
|
private _densestPos: { x: number; y: number }[] = [];
|
|
private _densestResult: EntityId[] = [];
|
|
private _densestCentroid = { x: 0, y: 0 };
|
|
private _sortPosA = { x: 0, y: 0 };
|
|
private _sortPosB = { x: 0, y: 0 };
|
|
private _sortScratch: EntityId[] = [];
|
|
private _coneScratch: EntityId[] = [];
|
|
private _footprintCentersScratch: { x: number; y: number }[] = [];
|
|
private _rangeOrigin = { x: 0, y: 0 };
|
|
/** 占地圆心(格子坐标) */
|
|
private _originCell = { row: 0, col: 0 };
|
|
/** 怪物格坐标 scratch */
|
|
private _monsterCell = { row: 0, col: 0 };
|
|
private _facePosScratch = { x: 0, y: 0 };
|
|
/** atk_type=15 主目标圆心扩展 scratch */
|
|
private _aroundScratch: EntityId[] = [];
|
|
|
|
constructor(
|
|
private readonly _world: World,
|
|
private readonly _attributes: AttributeSystem,
|
|
private readonly _spatial: SpatialGrid,
|
|
private readonly _grid: GridMap,
|
|
) {}
|
|
|
|
/**
|
|
* @param ctx 技能上下文
|
|
* @param spec 目标选取配置(params.range / AttrId.Range 均为像素)
|
|
* @returns 目标实体 ID 列表
|
|
*/
|
|
resolve(ctx: AbilityContext, spec: TargetSpec): EntityId[] {
|
|
const world = this._world;
|
|
const casterTf = world.getComponent(ctx.caster, TransformComp);
|
|
if (!casterTf) return [];
|
|
|
|
if (spec.team === 'self') {
|
|
return [ctx.caster];
|
|
}
|
|
|
|
if (spec.selector === 'AllEnemies') {
|
|
return this._resolveAllEnemies(ctx, spec);
|
|
}
|
|
|
|
const baseRange = spec.rangeRef === 'attackRange'
|
|
? this._attributes.getAttr(ctx.caster, AttrId.Range)
|
|
: (spec.params?.range as number) ?? this._attributes.getAttr(ctx.caster, AttrId.Range);
|
|
const rangeAdd = (spec.params?.rangeAdd as number) ?? 0;
|
|
// 大招用 params.range 时叠加词条 ult_range_add(像素);普攻已写入 AttrId.Range
|
|
const ultAdd = spec.rangeRef === 'attackRange'
|
|
? 0
|
|
: (this._attributes.getAttr(ctx.caster, 'ult_range_add') || 0);
|
|
const finalRangePx = Math.max(0, baseRange + rangeAdd + ultAdd);
|
|
|
|
const casterId = ctx.caster;
|
|
const casterOrigin = this._casterRangeOrigin(casterId);
|
|
const originCell = this._casterRangeOriginCells(casterId);
|
|
const rangeCells = finalRangePx / BattleMapSpec.CELL_SIZE;
|
|
this._originCell.row = Math.floor(originCell.row);
|
|
this._originCell.col = Math.floor(originCell.col);
|
|
const candidates = this._spatial.queryRadius(this._originCell, rangeCells + 1);
|
|
|
|
const casterTeam = world.getComponent(ctx.caster, TeamComp);
|
|
const stageStrategy = (spec.params?.stageStrategy as number) ?? 1;
|
|
const filtered: EntityId[] = [];
|
|
for (const id of candidates) {
|
|
if (id === ctx.caster) continue;
|
|
const hp = world.getComponent(id, HealthComp);
|
|
const tf = world.getComponent(id, TransformComp);
|
|
if (!hp || !tf) continue;
|
|
// 出生跳出保护:不可被任何单位索敌
|
|
if (world.getComponent(id, MovementComp)?.spawnProtected) continue;
|
|
const moveComp = world.getComponent(id, MovementComp);
|
|
// 阎王伸爪拖入中 / 已囚禁:不可被其它单位索敌
|
|
if (isYanSoulClaimed(moveComp)) continue;
|
|
// 芭蕉扇吹退中:暂时脱离索敌
|
|
if (isBajiaoBlowRetreating(moveComp)) continue;
|
|
const team = world.getComponent(id, TeamComp);
|
|
if (!this._passMonsterTeam(spec, casterTeam, team, world, id)) continue;
|
|
if (stageStrategy === 4) {
|
|
const tagComp = world.getComponent(id, TagComp);
|
|
const tags = tagComp?.tags;
|
|
if (tags) {
|
|
let skip = false;
|
|
for (let ti = 0; ti < tags.length; ti++) {
|
|
const t = tags[ti];
|
|
if (t === EntityTag.Boss || t === EntityTag.Elite) {
|
|
skip = true;
|
|
break;
|
|
}
|
|
}
|
|
if (skip) continue;
|
|
}
|
|
}
|
|
this._monsterCellInto(id, tf, this._monsterCell);
|
|
const distCells = Math.hypot(
|
|
originCell.row - this._monsterCell.row,
|
|
originCell.col - this._monsterCell.col,
|
|
);
|
|
if (distCells > rangeCells) continue;
|
|
this._entityWorldPosInto(id, this._sortPosA, tf);
|
|
filtered.push(id);
|
|
}
|
|
this._addSameSideCaster(ctx, spec, filtered);
|
|
|
|
if (filtered.length === 0) return [];
|
|
|
|
// atk_type 6 扇形:朝威胁最高的怪开口,圈内仍按威胁排序
|
|
let pool = filtered;
|
|
const posA = this._sortPosA;
|
|
const posB = this._sortPosB;
|
|
const angleDeg = Number(spec.params?.angle);
|
|
if (Number.isFinite(angleDeg) && angleDeg > 0 && angleDeg < 360) {
|
|
const faceId = this._pickFaceTargetId(pool, casterOrigin, spec, posA, posB);
|
|
if (faceId == null) return [];
|
|
this._entityWorldPosInto(faceId, this._facePosScratch);
|
|
const facingOrigin = this._casterFacingOrigin(casterId, this._facePosScratch);
|
|
const coneOrdered = this._sortedEntityCopy(pool, (a, b) =>
|
|
this._compareThreatPriority(a, b, casterOrigin, posA, posB));
|
|
pool = this._filterInFacingCone(facingOrigin, faceId, coneOrdered, angleDeg);
|
|
}
|
|
|
|
const rectWidthPx = Number(spec.params?.rectWidthPx);
|
|
if (spec.shape === 'Rect' && Number.isFinite(rectWidthPx) && rectWidthPx > 0 && pool.length > 0) {
|
|
const faceId = this._pickFaceTargetId(pool, casterOrigin, spec, posA, posB);
|
|
if (faceId == null) return [];
|
|
this._entityWorldPosInto(faceId, this._facePosScratch);
|
|
const facingOrigin = this._casterFacingOrigin(casterId, this._facePosScratch);
|
|
pool = this._filterInFacingRect(
|
|
facingOrigin,
|
|
this._facePosScratch,
|
|
pool,
|
|
finalRangePx,
|
|
rectWidthPx,
|
|
);
|
|
if (pool.length === 0) return [];
|
|
}
|
|
|
|
let ordered = this._orderBySelector(pool, this._originCell, casterOrigin, spec, ctx);
|
|
if (spec.params?.monsterRandomPick) this._shuffleEntities(ordered, ctx.rng);
|
|
if (ordered.length === 0) return [];
|
|
|
|
// atk_type=15:射程内锁主目标(跳过蟠桃树),再收「以主目标为圆心」半径内的敌(可含射程外)
|
|
const aroundRadiusPx = Number(spec.params?.aroundPrimaryRadius);
|
|
if (Number.isFinite(aroundRadiusPx) && aroundRadiusPx > 0) {
|
|
let primaryId: EntityId = 0;
|
|
for (let i = 0; i < ordered.length; i++) {
|
|
if (isPeachTreeTower(world, ordered[i])) continue;
|
|
primaryId = ordered[i];
|
|
break;
|
|
}
|
|
if (!(primaryId > 0)) return [];
|
|
ordered = this._collectAroundPrimary(ctx, spec, primaryId, aroundRadiusPx);
|
|
if (ordered.length === 0) return [];
|
|
}
|
|
|
|
// base_para 类型5 → target_num_add:叠到索敌个数,再走 target_stage(去重/可重复等)
|
|
const numAdd = Math.max(0, Math.floor(
|
|
this._attributes.getAttr(ctx.caster, 'target_num_add') || 0,
|
|
));
|
|
const pickCount = Math.max(1, Math.floor(
|
|
(spec.params?.pickCount as number) ?? spec.maxCount,
|
|
));
|
|
const placementCount = Math.max(1, pickCount + numAdd);
|
|
const stageOpts = this._stagePickOptions(ctx, spec);
|
|
// 多发落点 / 多段点名:在施法射程 ordered 池上走 target_stage。
|
|
return applyTargetStageStrategy(ordered, placementCount, stageStrategy, stageOpts);
|
|
}
|
|
|
|
/** target_type=4:全场敌方,不按塔射程过滤 */
|
|
private _resolveAllEnemies(ctx: AbilityContext, spec: TargetSpec): EntityId[] {
|
|
const world = this._world;
|
|
const casterTeam = world.getComponent(ctx.caster, TeamComp);
|
|
const stageStrategy = (spec.params?.stageStrategy as number) ?? 1;
|
|
const casterOrigin = this._casterRangeOrigin(ctx.caster);
|
|
this._originCell.row = Math.floor(this._grid.rows * 0.5);
|
|
this._originCell.col = Math.floor(this._grid.cols * 0.5);
|
|
const candidates = this._spatial.queryAll();
|
|
|
|
const filtered: EntityId[] = [];
|
|
for (const id of candidates) {
|
|
if (id === ctx.caster) continue;
|
|
const hp = world.getComponent(id, HealthComp);
|
|
const tf = world.getComponent(id, TransformComp);
|
|
if (!hp || !tf) continue;
|
|
if (world.getComponent(id, MovementComp)?.spawnProtected) continue;
|
|
const moveComp = world.getComponent(id, MovementComp);
|
|
if (isYanSoulClaimed(moveComp)) continue;
|
|
if (isBajiaoBlowRetreating(moveComp)) continue;
|
|
const team = world.getComponent(id, TeamComp);
|
|
if (!this._passMonsterTeam(spec, casterTeam, team, world, id)) continue;
|
|
if (stageStrategy === 4) {
|
|
const tagComp = world.getComponent(id, TagComp);
|
|
const tags = tagComp?.tags;
|
|
if (tags) {
|
|
let skip = false;
|
|
for (let ti = 0; ti < tags.length; ti++) {
|
|
const t = tags[ti];
|
|
if (t === EntityTag.Boss || t === EntityTag.Elite) {
|
|
skip = true;
|
|
break;
|
|
}
|
|
}
|
|
if (skip) continue;
|
|
}
|
|
}
|
|
filtered.push(id);
|
|
}
|
|
this._addSameSideCaster(ctx, spec, filtered);
|
|
|
|
if (filtered.length === 0) return [];
|
|
|
|
const ordered = this._orderBySelector(filtered, this._originCell, casterOrigin, spec, ctx);
|
|
if (spec.params?.monsterRandomPick) this._shuffleEntities(ordered, ctx.rng);
|
|
const numAdd = Math.max(0, Math.floor(
|
|
this._attributes.getAttr(ctx.caster, 'target_num_add') || 0,
|
|
));
|
|
const pickCount = Math.max(1, Math.floor(
|
|
(spec.params?.pickCount as number) ?? spec.maxCount,
|
|
));
|
|
const placementCount = Math.max(1, pickCount + numAdd);
|
|
return applyTargetStageStrategy(
|
|
ordered,
|
|
placementCount,
|
|
stageStrategy,
|
|
this._stagePickOptions(ctx, spec),
|
|
);
|
|
}
|
|
|
|
private _stagePickOptions(ctx: AbilityContext, spec: TargetSpec): TargetStageStrategyOptions {
|
|
const stageRangePx = Math.max(0, (spec.params?.stageRangePx as number) ?? 0);
|
|
const posScratch = { x: 0, y: 0 };
|
|
return {
|
|
rng: ctx.rng,
|
|
stageRangePx,
|
|
getWorldPos: (id) => {
|
|
const tf = this._world.getComponent(id, TransformComp);
|
|
if (!tf) return null;
|
|
this._entityWorldPosInto(id, posScratch, tf);
|
|
return posScratch;
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* atk_type=15:以主目标为圆心收圆内敌;主目标置首。
|
|
* 半径与黄圈同一套:配表像素 / 83 = 格,按格心距;并扫格子占地,避免只靠空间桶漏塔。
|
|
* @param ctx 技能上下文
|
|
* @param spec 索敌
|
|
* @param primaryId 射程内主目标
|
|
* @param radiusPx 圆半径(配表像素)
|
|
*/
|
|
private _collectAroundPrimary(ctx: AbilityContext, spec: TargetSpec, primaryId: EntityId, radiusPx: number): EntityId[] {
|
|
const world = this._world;
|
|
if (!world.getComponent(primaryId, TransformComp)) return [];
|
|
const origin = this._casterRangeOriginCells(primaryId);
|
|
const originRow = origin.row;
|
|
const originCol = origin.col;
|
|
const centerRow = Math.floor(originRow);
|
|
const centerCol = Math.floor(originCol);
|
|
const radiusCells = radiusPx / BattleMapSpec.CELL_SIZE;
|
|
this._originCell.row = centerRow;
|
|
this._originCell.col = centerCol;
|
|
const candidates = this._spatial.queryRadius(this._originCell, radiusCells + 1);
|
|
const casterTeam = world.getComponent(ctx.caster, TeamComp);
|
|
const out = this._aroundScratch;
|
|
out.length = 0;
|
|
out.push(primaryId);
|
|
for (let i = 0; i < candidates.length; i++) {
|
|
this._tryAddAroundPrimary(out, candidates[i], ctx, spec, primaryId, originRow, originCol, radiusCells, casterTeam);
|
|
}
|
|
const scanR = Math.ceil(radiusCells + 1);
|
|
for (let dr = -scanR; dr <= scanR; dr++) {
|
|
for (let dc = -scanR; dc <= scanR; dc++) {
|
|
if (Math.hypot(dr, dc) > radiusCells + 1) continue;
|
|
this._originCell.row = centerRow + dr;
|
|
this._originCell.col = centerCol + dc;
|
|
const occ = this._grid.getCell(this._originCell)?.occupant;
|
|
if (occ) this._tryAddAroundPrimary(out, occ, ctx, spec, primaryId, originRow, originCol, radiusCells, casterTeam);
|
|
}
|
|
}
|
|
return out.slice();
|
|
}
|
|
|
|
/**
|
|
* atk_type=15:圆内补一个候选(去重;组合字仍走 monsterEnemyHero 排除)。
|
|
* @param out 已收目标
|
|
* @param id 候选
|
|
* @param ctx 技能上下文
|
|
* @param spec 索敌
|
|
* @param primaryId 圆心主目标
|
|
* @param originRow 主目标格心行
|
|
* @param originCol 主目标格心列
|
|
* @param radiusCells 半径(格)
|
|
* @param casterTeam 施法者阵营
|
|
*/
|
|
private _tryAddAroundPrimary(out: EntityId[], id: EntityId, ctx: AbilityContext, spec: TargetSpec, primaryId: EntityId, originRow: number, originCol: number, radiusCells: number, casterTeam: TeamComp | null | undefined): void {
|
|
if (id === primaryId || id === ctx.caster) return;
|
|
for (let i = 0; i < out.length; i++) {
|
|
if (out[i] === id) return;
|
|
}
|
|
const world = this._world;
|
|
const hp = world.getComponent(id, HealthComp);
|
|
const tf = world.getComponent(id, TransformComp);
|
|
if (!hp || !tf) return;
|
|
if (world.getComponent(id, MovementComp)?.spawnProtected) return;
|
|
const moveComp = world.getComponent(id, MovementComp);
|
|
if (isYanSoulClaimed(moveComp)) return;
|
|
if (isBajiaoBlowRetreating(moveComp)) return;
|
|
const team = world.getComponent(id, TeamComp);
|
|
if (!this._passMonsterTeam(spec, casterTeam, team, world, id)) return;
|
|
const stageStrategy = (spec.params?.stageStrategy as number) ?? 1;
|
|
if (stageStrategy === 4) {
|
|
const tags = world.getComponent(id, TagComp)?.tags;
|
|
if (tags) {
|
|
for (let ti = 0; ti < tags.length; ti++) {
|
|
const t = tags[ti];
|
|
if (t === EntityTag.Boss || t === EntityTag.Elite) return;
|
|
}
|
|
}
|
|
}
|
|
this._monsterCellInto(id, tf, this._monsterCell);
|
|
if (Math.hypot(originRow - this._monsterCell.row, originCol - this._monsterCell.col) > radiusCells) return;
|
|
out.push(id);
|
|
}
|
|
|
|
/**
|
|
* 以 caster→faceId 为轴,保留夹角 ≤ angleDeg/2 的目标。
|
|
* 180° → 前半球;始终至少保留 faceId。
|
|
*/
|
|
private _filterInFacingCone(
|
|
casterPos: { x: number; y: number },
|
|
faceId: EntityId,
|
|
ordered: readonly EntityId[],
|
|
angleDeg: number,
|
|
): EntityId[] {
|
|
this._entityWorldPosInto(faceId, this._facePosScratch);
|
|
const facePos = this._facePosScratch;
|
|
const fdx = facePos.x - casterPos.x;
|
|
const fdy = facePos.y - casterPos.y;
|
|
const fLen = Math.hypot(fdx, fdy);
|
|
if (fLen < 1e-4) return ordered.slice();
|
|
|
|
const cosHalf = Math.cos((angleDeg * 0.5) * Math.PI / 180);
|
|
const out = this._coneScratch;
|
|
out.length = 0;
|
|
const p = this._sortPosA;
|
|
for (let i = 0; i < ordered.length; i++) {
|
|
const id = ordered[i];
|
|
if (id === faceId) {
|
|
out.push(id);
|
|
continue;
|
|
}
|
|
this._entityWorldPosInto(id, p);
|
|
const dx = p.x - casterPos.x;
|
|
const dy = p.y - casterPos.y;
|
|
const len = Math.hypot(dx, dy);
|
|
if (len < 1e-4) {
|
|
out.push(id);
|
|
continue;
|
|
}
|
|
const cos = (fdx * dx + fdy * dy) / (fLen * len);
|
|
if (cos + 1e-6 >= cosHalf) out.push(id);
|
|
}
|
|
return out.length > 0 ? out.slice() : [faceId];
|
|
}
|
|
|
|
/** 朝向基准:敌方按威胁(离目标营地最近),否则距施法者最近 */
|
|
private _pickFaceTargetId(
|
|
pool: readonly EntityId[],
|
|
casterOrigin: { x: number; y: number },
|
|
spec: TargetSpec,
|
|
posA: { x: number; y: number },
|
|
posB: { x: number; y: number },
|
|
): EntityId | null {
|
|
if (pool.length === 0) return null;
|
|
const ordered = spec.team === 'enemy'
|
|
? this._sortedEntityCopy(pool, (a, b) =>
|
|
this._compareThreatPriority(a, b, casterOrigin, posA, posB))
|
|
: this._sortedEntityCopy(pool, (a, b) => {
|
|
this._entityWorldPosInto(a, posA);
|
|
this._entityWorldPosInto(b, posB);
|
|
return this._distPx(casterOrigin, posA)
|
|
- this._distPx(casterOrigin, posB);
|
|
});
|
|
return ordered[0] ?? null;
|
|
}
|
|
|
|
/**
|
|
* atk_type 2/5:origin→facePos 前向矩形。
|
|
* 长=射程(朝向),宽=rectWidthPx(左右各一半)。
|
|
* 占地盒与条带相交即命中(边沿进入),不再外扩半格。
|
|
*/
|
|
private _filterInFacingRect(
|
|
origin: { x: number; y: number },
|
|
facePos: { x: number; y: number },
|
|
ordered: readonly EntityId[],
|
|
lengthPx: number,
|
|
widthPx: number,
|
|
): EntityId[] {
|
|
const fdx = facePos.x - origin.x;
|
|
const fdy = facePos.y - origin.y;
|
|
const fLen = Math.hypot(fdx, fdy);
|
|
const fx = fLen >= 1e-4 ? fdx / fLen : 1;
|
|
const fy = fLen >= 1e-4 ? fdy / fLen : 0;
|
|
const px = -fy;
|
|
const py = fx;
|
|
|
|
const halfCell = this._grid.cellSize * 0.5;
|
|
const halfW = Math.max(0, widthPx) * 0.5;
|
|
const lenMax = Math.max(0, lengthPx);
|
|
|
|
const out = this._coneScratch;
|
|
out.length = 0;
|
|
const p = this._sortPosA;
|
|
for (let i = 0; i < ordered.length; i++) {
|
|
const id = ordered[i];
|
|
this._entityWorldPosInto(id, p);
|
|
if (this._targetBodyOverlapsFacingRect(origin, fx, fy, px, py, lenMax, halfW, halfCell, p)) {
|
|
out.push(id);
|
|
}
|
|
}
|
|
return out.slice();
|
|
}
|
|
|
|
/**
|
|
* 占地盒投影区间与朝向矩形是否相交。
|
|
* 矩形:前向 [0, length]、侧向 [-halfW, halfW];盒用四角,不再另加 pad。
|
|
*/
|
|
private _targetBodyOverlapsFacingRect(
|
|
origin: { x: number; y: number },
|
|
fx: number,
|
|
fy: number,
|
|
px: number,
|
|
py: number,
|
|
lengthPx: number,
|
|
halfWidthPx: number,
|
|
halfCell: number,
|
|
targetCenter: { x: number; y: number },
|
|
): boolean {
|
|
const cx = targetCenter.x;
|
|
const cy = targetCenter.y;
|
|
let minF = Infinity;
|
|
let maxF = -Infinity;
|
|
let minL = Infinity;
|
|
let maxL = -Infinity;
|
|
for (let sx = -1; sx <= 1; sx += 2) {
|
|
for (let sy = -1; sy <= 1; sy += 2) {
|
|
const dx = cx + sx * halfCell - origin.x;
|
|
const dy = cy + sy * halfCell - origin.y;
|
|
const f = dx * fx + dy * fy;
|
|
const l = dx * px + dy * py;
|
|
if (f < minF) minF = f;
|
|
if (f > maxF) maxF = f;
|
|
if (l < minL) minL = l;
|
|
if (l > maxL) maxL = l;
|
|
}
|
|
}
|
|
return maxF >= 0
|
|
&& minF <= lengthPx
|
|
&& maxL >= -halfWidthPx
|
|
&& minL <= halfWidthPx;
|
|
}
|
|
|
|
private _copyEntityScratch(source: readonly EntityId[]): EntityId[] {
|
|
const scratch = this._sortScratch;
|
|
scratch.length = source.length;
|
|
for (let i = 0; i < source.length; i++) scratch[i] = source[i];
|
|
return scratch;
|
|
}
|
|
|
|
private _sortedEntityCopy(
|
|
source: readonly EntityId[],
|
|
compareFn: (a: EntityId, b: EntityId) => number,
|
|
): EntityId[] {
|
|
const scratch = this._copyEntityScratch(source);
|
|
scratch.sort(compareFn);
|
|
return scratch.slice();
|
|
}
|
|
|
|
private _orderBySelector(
|
|
filtered: EntityId[],
|
|
center: Cell,
|
|
casterOrigin: { x: number; y: number },
|
|
spec: TargetSpec,
|
|
ctx: AbilityContext,
|
|
): EntityId[] {
|
|
const world = this._world;
|
|
const posA = this._sortPosA;
|
|
const posB = this._sortPosB;
|
|
if (spec.team === 'enemy') {
|
|
return this._sortedEntityCopy(filtered, (a, b) =>
|
|
this._compareThreatPriority(a, b, casterOrigin, posA, posB));
|
|
}
|
|
switch (spec.selector) {
|
|
case 'LowestHp':
|
|
return this._sortedEntityCopy(filtered, (a, b) =>
|
|
world.getComponent(a, HealthComp)!.hp - world.getComponent(b, HealthComp)!.hp);
|
|
case 'HighestHp':
|
|
return this._sortedEntityCopy(filtered, (a, b) =>
|
|
world.getComponent(b, HealthComp)!.hp - world.getComponent(a, HealthComp)!.hp);
|
|
case 'Random':
|
|
return this._sortedEntityCopy(filtered, () => ctx.rng.next() - 0.5);
|
|
case 'AllInRange':
|
|
return filtered.slice();
|
|
case 'Densest':
|
|
return this._densestOrder(filtered, center, casterOrigin, spec);
|
|
case 'MostThreatening':
|
|
return this._sortedEntityCopy(filtered, (a, b) =>
|
|
this._compareThreatPriority(a, b, casterOrigin, posA, posB));
|
|
case 'AllEnemies':
|
|
case 'Nearest':
|
|
default:
|
|
return this._sortedEntityCopy(filtered, (a, b) => {
|
|
this._entityWorldPosInto(a, posA);
|
|
this._entityWorldPosInto(b, posB);
|
|
return this._distPx(casterOrigin, posA)
|
|
- this._distPx(casterOrigin, posB);
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* target_stage=2 同侧友军:施法者自己也算目标(太白回血/加速)。
|
|
* @param ctx 施法上下文
|
|
* @param spec 索敌
|
|
* @param filtered 已筛列表
|
|
*/
|
|
private _addSameSideCaster(ctx: AbilityContext, spec: TargetSpec, filtered: EntityId[]): void {
|
|
if (!spec.params?.monsterSameSide || spec.params?.monsterDevourMinion) return;
|
|
const id = ctx.caster;
|
|
for (let i = 0; i < filtered.length; i++) {
|
|
if (filtered[i] === id) return;
|
|
}
|
|
const world = this._world;
|
|
if (!world.isAlive(id)) return;
|
|
const hp = world.getComponent(id, HealthComp);
|
|
if (!hp || !(hp.hp > 0)) return;
|
|
if (world.getComponent(id, MovementComp)?.spawnProtected) return;
|
|
if (!this._isMonsterUnit(world, id)) return;
|
|
filtered.push(id);
|
|
}
|
|
|
|
/**
|
|
* 阵营与吞噬过滤。
|
|
* @param spec 索敌
|
|
* @param casterTeam 施法者
|
|
* @param team 候选
|
|
* @param world 世界
|
|
* @param id 候选实体
|
|
*/
|
|
private _passMonsterTeam(
|
|
spec: TargetSpec,
|
|
casterTeam: TeamComp | null | undefined,
|
|
team: TeamComp | null | undefined,
|
|
world: World,
|
|
id: EntityId,
|
|
): boolean {
|
|
if (spec.params?.monsterDevourMinion) {
|
|
return BattleTeamId.isSameSide(casterTeam, team) && this._isDevourMinion(world, id);
|
|
}
|
|
if (spec.params?.monsterSameSide) {
|
|
return BattleTeamId.isSameSide(casterTeam, team) && this._isMonsterUnit(world, id);
|
|
}
|
|
if (spec.params?.monsterHostile) {
|
|
return BattleTeamId.isHostile(casterTeam, team) && this._isMonsterUnit(world, id);
|
|
}
|
|
if (spec.team === 'ally') {
|
|
return this._isHeroAllyMarchUnit(casterTeam, team, world, id);
|
|
}
|
|
const match = spec.team === 'enemy'
|
|
? BattleTeamId.isHostile(casterTeam, team)
|
|
: BattleTeamId.isFriendly(casterTeam, team);
|
|
if (!match) return false;
|
|
if (spec.params?.monsterEnemyHero && !isMonsterDefaultEnemyTarget(world, id)) return false;
|
|
if (spec.params?.monsterBasicHero && !isMonsterBasicHeroTarget(world, id)) return false;
|
|
const weaponType = Math.floor(Number(spec.params?.weaponType) || 0);
|
|
if (weaponType > 0 && !isHeroWeaponTarget(world, id, weaponType)) return false;
|
|
return true;
|
|
}
|
|
|
|
/** 吞噬目标:本方小怪 / 召唤物 */
|
|
private _isDevourMinion(world: World, id: EntityId): boolean {
|
|
return this._isMonsterUnit(world, id) && MonsterDataManager.isDevourMinion(this._monsterId(world, id));
|
|
}
|
|
|
|
/** 是否为怪物单位(非塔) */
|
|
private _isMonsterUnit(world: World, id: EntityId): boolean {
|
|
return this._monsterId(world, id) > 0;
|
|
}
|
|
|
|
/**
|
|
* 英雄/武器 target_stage=2:己方出征口行军怪,或友方召唤怪。不含塔、不含攻向自己的敌军。
|
|
* @param casterTeam 施法者
|
|
* @param team 候选
|
|
* @param world 世界
|
|
* @param id 候选实体
|
|
*/
|
|
private _isHeroAllyMarchUnit(casterTeam: TeamComp | null | undefined, team: TeamComp | null | undefined, world: World, id: EntityId): boolean {
|
|
if (!this._isMonsterUnit(world, id)) return false;
|
|
if (BattleTeamId.isFriendly(casterTeam, team)) return true;
|
|
const meta = world.getComponent(id, UnitMetaComp);
|
|
const allyPort = (casterTeam?.ownerId ?? 0) === 0 ? 2 : 1;
|
|
return (meta?.spawnPoint ?? 0) === allyPort;
|
|
}
|
|
|
|
/** Monster 表 id;非怪为 0 */
|
|
private _monsterId(world: World, id: EntityId): number {
|
|
const meta = world.getComponent(id, UnitMetaComp);
|
|
if (!meta?.unitDefId) return 0;
|
|
const parsed = parseMonsterUnitDefId(meta.unitDefId);
|
|
return parsed?.monsterId ?? parseLegacyMonsterId(meta.unitDefId);
|
|
}
|
|
|
|
/** 原地洗牌(monsterRandomPick) */
|
|
private _shuffleEntities(list: EntityId[], rng: { next(): number }): void {
|
|
for (let i = list.length - 1; i > 0; i--) {
|
|
const j = Math.floor(rng.next() * (i + 1));
|
|
const t = list[i];
|
|
list[i] = list[j];
|
|
list[j] = t;
|
|
}
|
|
}
|
|
|
|
private _densestOrder(
|
|
filtered: EntityId[],
|
|
_center: Cell,
|
|
casterOrigin: { x: number; y: number },
|
|
spec: TargetSpec,
|
|
): EntityId[] {
|
|
const n = filtered.length;
|
|
if (n <= 1) return n === 1 ? [filtered[0]] : [];
|
|
|
|
const parent = this._densestParent;
|
|
const count = this._densestCount;
|
|
const sumX = this._densestSumX;
|
|
const sumY = this._densestSumY;
|
|
const positions = this._densestPos;
|
|
|
|
for (let i = 0; i < n; i++) {
|
|
parent[i] = i;
|
|
count[i] = 0;
|
|
sumX[i] = 0;
|
|
sumY[i] = 0;
|
|
if (!positions[i]) positions[i] = { x: 0, y: 0 };
|
|
this._entityWorldPosInto(filtered[i], positions[i]);
|
|
}
|
|
|
|
const clusterRadiusPx = (spec.params?.clusterRadius as number)
|
|
?? (1.5 * this._grid.cellSize);
|
|
const find = (i: number): number => parent[i] === i ? i : (parent[i] = find(parent[i]));
|
|
const union = (a: number, b: number) => { parent[find(a)] = find(b); };
|
|
|
|
for (let i = 0; i < n; i++) {
|
|
for (let j = i + 1; j < n; j++) {
|
|
if (this._distPx(positions[i], positions[j]) <= clusterRadiusPx) union(i, j);
|
|
}
|
|
}
|
|
|
|
for (let i = 0; i < n; i++) {
|
|
const r = find(i);
|
|
count[r]++;
|
|
sumX[r] += positions[i].x;
|
|
sumY[r] += positions[i].y;
|
|
}
|
|
|
|
let bestRoot = 0;
|
|
let bestCount = -1;
|
|
let bestDist = Infinity;
|
|
const centroid = this._densestCentroid;
|
|
|
|
for (let r = 0; r < n; r++) {
|
|
const c = count[r];
|
|
if (c <= 0) continue;
|
|
centroid.x = sumX[r] / c;
|
|
centroid.y = sumY[r] / c;
|
|
const d = this._distPx(casterOrigin, centroid);
|
|
if (c > bestCount || (c === bestCount && d < bestDist)) {
|
|
bestRoot = r;
|
|
bestCount = c;
|
|
bestDist = d;
|
|
}
|
|
}
|
|
|
|
const result = this._densestResult;
|
|
result.length = 0;
|
|
for (let i = 0; i < n; i++) {
|
|
if (find(i) === bestRoot) result.push(filtered[i]);
|
|
}
|
|
|
|
const posA = this._sortPosA;
|
|
const posB = this._sortPosB;
|
|
result.sort((a, b) => {
|
|
this._entityWorldPosInto(a, posA);
|
|
this._entityWorldPosInto(b, posB);
|
|
const da = this._distPx(casterOrigin, posA);
|
|
const db = this._distPx(casterOrigin, posB);
|
|
return da - db;
|
|
});
|
|
return result.slice();
|
|
}
|
|
|
|
private _entityWorldPosInto(
|
|
id: EntityId,
|
|
out: { x: number; y: number },
|
|
tf?: TransformComp,
|
|
): void {
|
|
const move = this._world.getComponent(id, MovementComp);
|
|
if (move && (move.worldX !== 0 || move.worldY !== 0)) {
|
|
out.x = move.worldX;
|
|
out.y = move.worldY;
|
|
return;
|
|
}
|
|
const t = tf ?? this._world.getComponent(id, TransformComp);
|
|
if (!t) {
|
|
out.x = 0;
|
|
out.y = 0;
|
|
return;
|
|
}
|
|
this._monsterCell.row = t.row;
|
|
this._monsterCell.col = t.col;
|
|
this._grid.cellToWorldInto(this._monsterCell, out);
|
|
}
|
|
|
|
/** 施法者全部占地格心;行军怪跟当前世界坐标,塔取 GridOccupancyComp.cells */
|
|
private _fillFootprintCenters(casterId: EntityId): { x: number; y: number }[] {
|
|
const out = this._footprintCentersScratch;
|
|
if (this._world.getComponent(casterId, MovementComp)) {
|
|
let slot = out[0];
|
|
if (!slot) {
|
|
slot = { x: 0, y: 0 };
|
|
out[0] = slot;
|
|
}
|
|
out.length = 1;
|
|
this._entityWorldPosInto(casterId, slot);
|
|
return out;
|
|
}
|
|
const occ = this._world.getComponent(casterId, GridOccupancyComp);
|
|
if (occ && occ.cells.length > 0) {
|
|
for (let i = 0; i < occ.cells.length; i++) {
|
|
let slot = out[i];
|
|
if (!slot) {
|
|
slot = { x: 0, y: 0 };
|
|
out[i] = slot;
|
|
}
|
|
this._grid.cellToWorldInto(occ.cells[i], slot);
|
|
}
|
|
out.length = occ.cells.length;
|
|
return out;
|
|
}
|
|
const tf = this._world.getComponent(casterId, TransformComp);
|
|
if (!tf) {
|
|
if (!out[0]) out.push({ x: 0, y: 0 });
|
|
else { out[0].x = 0; out[0].y = 0; }
|
|
out.length = 1;
|
|
return out;
|
|
}
|
|
this._monsterCell.row = tf.row;
|
|
this._monsterCell.col = tf.col;
|
|
let slot = out[0];
|
|
if (!slot) {
|
|
slot = { x: 0, y: 0 };
|
|
out.push(slot);
|
|
}
|
|
out.length = 1;
|
|
this._grid.cellToWorldInto(this._monsterCell, slot);
|
|
return out;
|
|
}
|
|
|
|
/** 扇形朝向原点:距目标最近的占地格心 */
|
|
private _casterFacingOrigin(
|
|
casterId: EntityId,
|
|
toward: { x: number; y: number },
|
|
): { x: number; y: number } {
|
|
const centers = this._fillFootprintCenters(casterId);
|
|
let best = centers[0];
|
|
let bestD = Infinity;
|
|
for (let i = 0; i < centers.length; i++) {
|
|
const c = centers[i];
|
|
const d = this._distPx(c, toward);
|
|
if (d < bestD) {
|
|
bestD = d;
|
|
best = c;
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
/** 攻击圈圆心:多格合成体取占地格心均值,与 UI 范围预览一致 */
|
|
private _casterRangeOrigin(casterId: EntityId): { x: number; y: number } {
|
|
const centers = this._fillFootprintCenters(casterId);
|
|
let cx = 0;
|
|
let cy = 0;
|
|
for (let i = 0; i < centers.length; i++) {
|
|
cx += centers[i].x;
|
|
cy += centers[i].y;
|
|
}
|
|
const n = Math.max(1, centers.length);
|
|
this._rangeOrigin.x = cx / n;
|
|
this._rangeOrigin.y = cy / n;
|
|
return this._rangeOrigin;
|
|
}
|
|
|
|
/** 攻击圈圆心(格子坐标,格心);行军怪跟当前格,不跟出生占地 */
|
|
private _casterRangeOriginCells(casterId: EntityId): { row: number; col: number } {
|
|
const tf = this._world.getComponent(casterId, TransformComp);
|
|
if (tf && this._world.getComponent(casterId, MovementComp)) {
|
|
this._monsterCellInto(casterId, tf, this._originCell);
|
|
return this._originCell;
|
|
}
|
|
const occ = this._world.getComponent(casterId, GridOccupancyComp);
|
|
if (occ && occ.cells.length > 0) {
|
|
let r = 0;
|
|
let c = 0;
|
|
for (let i = 0; i < occ.cells.length; i++) {
|
|
r += occ.cells[i].row + 0.5;
|
|
c += occ.cells[i].col + 0.5;
|
|
}
|
|
const n = occ.cells.length;
|
|
this._originCell.row = r / n;
|
|
this._originCell.col = c / n;
|
|
return this._originCell;
|
|
}
|
|
this._originCell.row = (tf?.row ?? 0) + 0.5;
|
|
this._originCell.col = (tf?.col ?? 0) + 0.5;
|
|
return this._originCell;
|
|
}
|
|
|
|
/**
|
|
* 怪物格子坐标(格心)。worldX/Y 与当前格 cellToWorld 同系时加上格内插值。
|
|
* @param id 怪物
|
|
* @param tf 当前格
|
|
* @param out 写入格坐标
|
|
*/
|
|
private _monsterCellInto(id: EntityId, tf: TransformComp, out: { row: number; col: number }): void {
|
|
out.row = tf.row + 0.5;
|
|
out.col = tf.col + 0.5;
|
|
const move = this._world.getComponent(id, MovementComp);
|
|
if (!move || (move.worldX === 0 && move.worldY === 0)) return;
|
|
const size = this._grid.cellSize;
|
|
if (!(size > 0)) return;
|
|
const hx = (tf.col + 0.5) * size;
|
|
const hy = (tf.row + 0.5) * size;
|
|
const ox = (move.worldX - hx) / size;
|
|
const oy = (move.worldY - hy) / size;
|
|
if (Math.abs(ox) > 1.5 || Math.abs(oy) > 1.5) return;
|
|
out.col = tf.col + 0.5 + ox;
|
|
out.row = tf.row + 0.5 + oy;
|
|
}
|
|
|
|
private _distPx(a: { x: number; y: number }, b: { x: number; y: number }): number {
|
|
const dx = a.x - b.x;
|
|
const dy = a.y - b.y;
|
|
return Math.sqrt(dx * dx + dy * dy);
|
|
}
|
|
|
|
/**
|
|
* 威胁:沿当前路到目标营地剩余越短越优先(已抵墙=0);
|
|
* 同剩余时距施法者更近者优先。
|
|
*/
|
|
private _compareThreatPriority(
|
|
a: EntityId,
|
|
b: EntityId,
|
|
casterOrigin: { x: number; y: number },
|
|
posA: { x: number; y: number },
|
|
posB: { x: number; y: number },
|
|
): number {
|
|
const pathDiff = this._remainingPathToGoalPx(a) - this._remainingPathToGoalPx(b);
|
|
if (pathDiff !== 0) return pathDiff;
|
|
this._entityWorldPosInto(a, posA);
|
|
this._entityWorldPosInto(b, posB);
|
|
return this._distPx(casterOrigin, posA)
|
|
- this._distPx(casterOrigin, posB);
|
|
}
|
|
|
|
/** 怪物沿当前寻路到目标营地(攻击点)的剩余逻辑像素 */
|
|
private _remainingPathToGoalPx(entityId: EntityId): number {
|
|
const move = this._world.getComponent(entityId, MovementComp);
|
|
if (!move) return Number.POSITIVE_INFINITY;
|
|
return move.remainPathPx;
|
|
}
|
|
}
|
|
|