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.
 
 
 
 
AdLike2048/assets/mx/module/battle/combat/TargetResolver.ts

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 ? 1 : 2;
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;
}
}