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; } }