import type { BattleSession } from 'db://assets/mx/module/battle'; import { AttrId, AttributeComp, CombatComp } from 'db://assets/mx/module/battle/ecs/Components'; import type { ComposedUnit } from 'db://assets/mx/module/merge'; import type { EntityId } from 'db://assets/mx/module/ecs'; import type { Player } from '../../game/Player'; import { TableNames } from '../../game/ConfigTableData'; import { WeaponComposeConfigProvider } from '../bag/WeaponComposeConfigProvider'; import type { MapManager } from '../map/legacy/MapManager'; import { composeKey } from '../runtime/ComposeUnitKey'; import { findEntityIdByGridIndex } from '../runtime/DeployEntityQuery'; import { resolveTowerHeroId } from '../config/BattleHeroTable'; /** 千里眼落点(背包槽 / 地图格) */ export type BattleGameAttackRangeDropTarget = | { kind: 'map'; gridIndex: number } | { kind: 'bag'; slotIndex: number; /** 背包槽对应 Hero.id,用于远程单位校验 */ bagWeapon1Id?: number }; /** 已绑定到具体远程单位的射程层 */ type AttackRangeLayer = { /** modifier source(buffId + skill 槽 + 落点唯一) */ source: string; /** 射程加成值(= Hero.atk_range,像素) */ bonusRange: number; /** 受益 ECS 实体 */ entityId: EntityId; /** buffId,用于叠加判定 */ buffId: number; }; /** 背包落点待绑定(字块上场后再挂到对应单位) */ type PendingBagAttackRange = { /** 背包槽索引 */ bagSlot: number; source: string; bonusRange: number; buffId: number; /** 是否已绑定到实体 */ applied: boolean; }; /** 背包→地图落点后待重试绑定(DeploySync 晚于 drop 一帧时) */ type DeferredBagPlaceBind = { bagSlot: number; gridIndex: number; buffId: number; source: string; bonusRange: number; }; /** 射程 buff modifier source 前缀 */ const RANGE_SOURCE_PREFIX = 'battle_buff_range_'; /** * 局内主动千里眼(functiontype=18)宿主。 * 落点字块类型与封神榜/授仙箓一致(多格组合字已合成武将 / type1 普通单字;type2 未合成散字不可); * 仅作用于落点对应的远程攻击单位(Hero.type===2); * 每个 buffId 对同一单位仅可成功施放一次(背包预绑与上场后为同一实例;同 buffId 不叠加,不同 buffId 可叠加)。 */ export class BattleGameGlobalAttackRangeHost { private static _inst: BattleGameGlobalAttackRangeHost | null = null; /** 已绑定单位的射程层 */ private _layers: AttackRangeLayer[] = []; /** 背包落点待绑定 */ private _pendingBag: PendingBagAttackRange[] = []; /** 落点待重试绑定 */ private _deferredBagPlace: DeferredBagPlaceBind[] = []; /** 已施放单位:buffId + 单位 usageKey(同 buffId 同单位仅一次) */ private _usedTargets: { buffId: number; targetKey: string }[] = []; /** 背包槽 → 上场实体(同一字块实例 bag / map 共用去重) */ private _bagSlotEntityLinks: { bagSlot: number; entityId: EntityId }[] = []; /** 单例 */ static get(): BattleGameGlobalAttackRangeHost { if (!BattleGameGlobalAttackRangeHost._inst) { BattleGameGlobalAttackRangeHost._inst = new BattleGameGlobalAttackRangeHost(); } return BattleGameGlobalAttackRangeHost._inst; } /** 重置局内状态(战斗结束) */ reset(): void { this._layers.length = 0; this._pendingBag.length = 0; this._deferredBagPlace.length = 0; this._usedTargets.length = 0; this._bagSlotEntityLinks.length = 0; } /** * 快照实体已绑定的千里眼。 * @param entityId 塔实体 */ snapshot(entityId: EntityId): { buffId: number; source: string; value: number }[] { const out: { buffId: number; source: string; value: number }[] = []; for (let i = 0; i < this._layers.length; i++) { const layer = this._layers[i]; if (layer.entityId !== entityId) continue; out.push({ buffId: layer.buffId, source: layer.source, value: layer.bonusRange }); } return out; } /** * 拆塔后把千里眼绑回新实体(近战跳过)。 * @param session 战斗会话 * @param entityId 新实体 * @param buffs 快照 */ restore(session: BattleSession, entityId: EntityId, buffs: readonly { buffId: number; source: string; value: number }[]): void { if (!this.isEntityRemoteHero(entityId, session)) return; for (let i = 0; i < buffs.length; i++) { const b = buffs[i]; if (this._hasEntityBuffLayer(b.buffId, entityId)) continue; const bonus = this.getRemoteRangeBonus(entityId, session); this._bindLayer(session, entityId, b.source, bonus > 0 ? bonus : b.value, b.buffId); this._markTargetUsed(b.buffId, `entity:${entityId}`); } } /** * 实体卖掉后清层(字上的绑定快照仍保留)。 * @param entityId 塔实体 */ forgetEntity(entityId: EntityId): void { if (!(entityId > 0)) return; for (let i = this._layers.length - 1; i >= 0; i--) { if (this._layers[i].entityId === entityId) this._layers.splice(i, 1); } for (let i = this._bagSlotEntityLinks.length - 1; i >= 0; i--) { if (this._bagSlotEntityLinks[i].entityId === entityId) this._bagSlotEntityLinks.splice(i, 1); } } /** * 指定 buffId 是否已对目标单位施放过。 * @param buffId Battlebuff.id(functiontype=18) */ isTargetUsed( buffId: number, target: BattleGameAttackRangeDropTarget, session: BattleSession, player: Player | null, map: MapManager | null, ): boolean { if (buffId <= 0) return false; if (target.kind === 'map') { const entityId = this.resolveMapTargetEntityId(session, player, map, target.gridIndex); if (entityId != null && this._hasEntityBuffLayer(buffId, entityId)) return true; } const keys = this._collectUsageKeys(buffId, session, player, map, target); for (let i = 0; i < keys.length; i++) { if (this._hasUsedTarget(buffId, keys[i])) return true; } return false; } /** * 解析千里眼去重键:已上场远程 → entity;多格未上场 → compose;单字未上场 → map 格;背包 → bag 槽。 */ static resolveUsageKey( session: BattleSession, player: Player | null, map: MapManager | null, target: BattleGameAttackRangeDropTarget, ): string { if (target.kind === 'bag') { return `bag:${target.slotIndex}`; } const gridIndex = target.gridIndex; const host = BattleGameGlobalAttackRangeHost.get(); const entityId = host.resolveMapTargetEntityId(session, player, map, gridIndex); if (entityId != null) { return `entity:${entityId}`; } const compose = BattleGameGlobalAttackRangeHost._findCompositionAt(player, gridIndex); if (compose && BattleGameGlobalAttackRangeHost._isMultiCellComposition(compose)) { return `compose:${composeKey(compose)}`; } return `map:${gridIndex}`; } /** * Hero 是否为远程攻击单位(Hero.type===2)。 * @param heroId Hero 表 id(weapon1Id) */ static isRemoteHero(heroId: number): boolean { if (heroId <= 0) return false; const hero = gg.data.table.getTableData(TableNames.Hero, heroId); if (!hero) return false; return Number(hero.type) === 2; } /** 已部署实体是否为远程攻击单位 */ isEntityRemoteHero(entityId: EntityId, session: BattleSession): boolean { const heroId = resolveTowerHeroId(session.world, entityId); if (!(heroId > 0)) return false; return BattleGameGlobalAttackRangeHost.isRemoteHero(heroId); } /** * 检查实体是否为远程攻击单位(Hero.type===2)。 * @returns 远程单位时返回 Hero.atk_range,否则 0 */ getRemoteRangeBonus(entityId: EntityId, session: BattleSession): number { const heroId = resolveTowerHeroId(session.world, entityId); if (!(heroId > 0)) return 0; if (!BattleGameGlobalAttackRangeHost.isRemoteHero(heroId)) return 0; const hero = gg.data.table.getTableData(TableNames.Hero, heroId); if (!hero) return 0; return Number(hero.atk_range) || 0; } /** * 解析地图落点对应的已部署远程攻击单位。 * @returns 实体 ID;无远程单位时 null */ resolveMapTargetEntityId( session: BattleSession, player: Player | null, map: MapManager | null, gridIndex: number, ): EntityId | null { if (!player || !map) return null; const entityId = findEntityIdByGridIndex(gridIndex, session, map, player); if (entityId == null) return null; if (!session.world.isAlive(entityId)) return null; if (!session.world.getComponent(entityId, CombatComp)) return null; if (!this.isEntityRemoteHero(entityId, session)) return null; return entityId; } /** * 地图落点是否对应已部署的近战攻击单位(有单位但 Hero.type !== 2)。 * 用于拖拽千里眼到近战单位时给出提示。 */ isMapTargetMeleeUnit( session: BattleSession, player: Player | null, map: MapManager | null, gridIndex: number, ): boolean { if (!player || !map) return false; const entityId = findEntityIdByGridIndex(gridIndex, session, map, player); if (entityId == null) return false; if (!session.world.isAlive(entityId)) return false; if (!session.world.getComponent(entityId, CombatComp)) return false; return !this.isEntityRemoteHero(entityId, session); } /** * 拖拽落点施放:提升落点对应远程攻击单位射程(+Hero.atk_range)。 * @param buffId buff 配置 ID(去重 + source 标识) * @param skillSlot skill 槽(仅用于 source 标识不同槽位的同一 buffId) * @param target 落点字块 * @returns 是否成功 */ tryActivate( session: BattleSession, player: Player | null, map: MapManager | null, buffId: number, skillSlot: number, target: BattleGameAttackRangeDropTarget, ): boolean { if (!player) { console.log('[BattleGameAttackRange] 无 player'); return false; } if (buffId <= 0) { console.log('[BattleGameAttackRange] 非法 buffId', buffId); return false; } if (this.isTargetUsed(buffId, target, session, player, map)) { return false; } const usageKey = BattleGameGlobalAttackRangeHost.resolveUsageKey(session, player, map, target); const source = BattleGameGlobalAttackRangeHost._sourceFor(buffId, skillSlot, usageKey); if (target.kind === 'map') { const entityId = this.resolveMapTargetEntityId(session, player, map, target.gridIndex); if (entityId == null) return false; const bonusRange = this.getRemoteRangeBonus(entityId, session); if (bonusRange <= 0) return false; this._markTargetUsed(buffId, usageKey); this._bindLayer(session, entityId, source, bonusRange, buffId); this._syncHeroBindRange(player, target.gridIndex, entityId); return true; } if (target.bagWeapon1Id == null || !BattleGameGlobalAttackRangeHost.isRemoteHero(target.bagWeapon1Id)) { return false; } this._markTargetUsed(buffId, usageKey); this._pendingBag.push({ bagSlot: target.slotIndex, source, bonusRange: 0, buffId, applied: false, }); return true; } /** * 背包字块落到地图后,将待绑定射程挂到对应单位。 * @param bagSlot 来源背包槽 * @param gridIndex 落点地图格 */ onBagPlacedOnMap( session: BattleSession, player: Player | null, map: MapManager | null, bagSlot: number, gridIndex: number, ): void { if (!player || !map) return; const entityId = this._resolveMapCombatEntityId(session, player, map, gridIndex); if (entityId != null) { this._linkBagSlotEntity(bagSlot, entityId); this._bindPendingBagSlot(session, entityId, bagSlot); this._syncHeroBindRange(player, gridIndex, entityId); return; } const pending = this._findPendingBag(bagSlot); if (!pending || pending.applied) return; this._deferredBagPlace.push({ bagSlot, gridIndex, buffId: pending.buffId, source: pending.source, bonusRange: pending.bonusRange, }); } /** 塔部署/升级后补施加已绑定的射程 buff(仅该实体) */ refreshEntity(session: BattleSession, entityId: EntityId, player?: Player | null, map?: MapManager | null): void { if (player && map) { this._retryDeferredBagBind(session, player, map, entityId); } for (let i = 0; i < this._layers.length; i++) { const layer = this._layers[i]; if (layer.entityId !== entityId) continue; this._applyModifier(session, entityId, layer.source, layer.bonusRange); } } private _findPendingBag(bagSlot: number): PendingBagAttackRange | null { for (let i = 0; i < this._pendingBag.length; i++) { const pending = this._pendingBag[i]; if (pending.bagSlot === bagSlot && !pending.applied) return pending; } return null; } private _bindPendingBagSlot(session: BattleSession, entityId: EntityId, bagSlot: number): void { for (let i = 0; i < this._pendingBag.length; i++) { const pending = this._pendingBag[i]; if (pending.applied || pending.bagSlot !== bagSlot) continue; this._linkBagSlotEntity(bagSlot, entityId); this._markTargetUsed(pending.buffId, `entity:${entityId}`); const bonusRange = this.getRemoteRangeBonus(entityId, session); if (bonusRange > 0) { this._bindLayer(session, entityId, pending.source, bonusRange, pending.buffId); } pending.applied = true; return; } } /** 收集同一字块实例在 bag / map 上的全部去重键 */ private _collectUsageKeys( buffId: number, session: BattleSession, player: Player | null, map: MapManager | null, target: BattleGameAttackRangeDropTarget, ): string[] { const keys: string[] = []; const pushKey = (key: string): void => { if (keys.indexOf(key) >= 0) return; keys.push(key); }; pushKey(BattleGameGlobalAttackRangeHost.resolveUsageKey(session, player, map, target)); if (target.kind === 'bag') { const linkedEntity = this._findEntityForBagSlot(target.slotIndex); if (linkedEntity != null) { pushKey(`entity:${linkedEntity}`); } return keys; } const entityId = this.resolveMapTargetEntityId(session, player, map, target.gridIndex); if (entityId != null) { pushKey(`entity:${entityId}`); const linkedBagSlot = this._findBagSlotForEntity(entityId); if (linkedBagSlot != null) { pushKey(`bag:${linkedBagSlot}`); } } for (let i = 0; i < this._deferredBagPlace.length; i++) { const row = this._deferredBagPlace[i]; if (row.buffId !== buffId) continue; if (!BattleGameGlobalAttackRangeHost._isSameMapTileUnit(player, row.gridIndex, target.gridIndex)) continue; if (!this._hasUsedTarget(row.buffId, `bag:${row.bagSlot}`)) continue; pushKey(`bag:${row.bagSlot}`); } for (let i = 0; i < this._usedTargets.length; i++) { const row = this._usedTargets[i]; if (row.buffId !== buffId) continue; const bagPrefix = 'bag:'; if (!row.targetKey.startsWith(bagPrefix)) continue; const bagSlot = Number(row.targetKey.slice(bagPrefix.length)); if (!Number.isFinite(bagSlot)) continue; const linkedEntity = this._findEntityForBagSlot(bagSlot); if (entityId != null && linkedEntity === entityId) { pushKey(row.targetKey); continue; } for (let j = 0; j < this._deferredBagPlace.length; j++) { const def = this._deferredBagPlace[j]; if (def.bagSlot !== bagSlot || def.buffId !== buffId) continue; if (BattleGameGlobalAttackRangeHost._isSameMapTileUnit(player, def.gridIndex, target.gridIndex)) { pushKey(row.targetKey); break; } } } return keys; } /** 实体是否已绑定指定 buffId 的射程层 */ private _hasEntityBuffLayer(buffId: number, entityId: EntityId): boolean { for (let i = 0; i < this._layers.length; i++) { const layer = this._layers[i]; if (layer.buffId === buffId && layer.entityId === entityId) return true; } return false; } /** 两地图格是否同一字块实例(同格或多格组合占格) */ private static _isSameMapTileUnit(player: Player | null, gridA: number, gridB: number): boolean { if (gridA === gridB) return true; if (!player) return false; const compose = player.compositions.find(c => c.gridIndices.includes(gridA)) ?? null; if (!compose) return false; return compose.gridIndices.includes(gridB); } private _linkBagSlotEntity(bagSlot: number, entityId: EntityId): void { for (let i = 0; i < this._bagSlotEntityLinks.length; i++) { if (this._bagSlotEntityLinks[i].bagSlot === bagSlot) { this._bagSlotEntityLinks[i].entityId = entityId; return; } } this._bagSlotEntityLinks.push({ bagSlot, entityId }); } private _findBagSlotForEntity(entityId: EntityId): number | null { for (let i = 0; i < this._bagSlotEntityLinks.length; i++) { if (this._bagSlotEntityLinks[i].entityId === entityId) { return this._bagSlotEntityLinks[i].bagSlot; } } return null; } private _findEntityForBagSlot(bagSlot: number): EntityId | null { for (let i = 0; i < this._bagSlotEntityLinks.length; i++) { if (this._bagSlotEntityLinks[i].bagSlot === bagSlot) { return this._bagSlotEntityLinks[i].entityId; } } return null; } private _retryDeferredBagBind( session: BattleSession, player: Player, map: MapManager, entityId: EntityId, ): void { for (let i = this._deferredBagPlace.length - 1; i >= 0; i--) { const row = this._deferredBagPlace[i]; const atGrid = this.resolveMapTargetEntityId(session, player, map, row.gridIndex); if (atGrid !== entityId) continue; this._linkBagSlotEntity(row.bagSlot, entityId); this._markTargetUsed(row.buffId, `entity:${entityId}`); const bonusRange = this.getRemoteRangeBonus(entityId, session); if (bonusRange > 0) { this._bindLayer(session, entityId, row.source, bonusRange, row.buffId); } const pending = this._findPendingBag(row.bagSlot); if (pending) pending.applied = true; this._deferredBagPlace.splice(i, 1); this._syncHeroBindRange(player, row.gridIndex, entityId); } } /** * 千里眼变化后写回组合内每个字的绑定。 * @param player 玩家 * @param gridIndex 落点格 * @param entityId 塔实体 */ private _syncHeroBindRange(player: Player, gridIndex: number, entityId: EntityId): void { const compose = BattleGameGlobalAttackRangeHost._findCompositionAt(player, gridIndex); if (!compose) return; const merge = player.bagSession?.merge; if (!merge) return; const prev = merge.readCompositionHeroBind(compose); merge.writeCompositionHeroBind(compose, { weapon1Id: prev?.weapon1Id ?? compose.weapon1Id, level: prev?.level ?? compose.level, exp: prev?.exp ?? 0, aspd: prev?.aspd ?? [], range: this.snapshot(entityId), }); } /** 地图格对应已部署攻击单位(不限远程,用于 bag↔map 去重链接) */ private _resolveMapCombatEntityId( session: BattleSession, player: Player | null, map: MapManager | null, gridIndex: number, ): EntityId | null { if (!player || !map) return null; const entityId = findEntityIdByGridIndex(gridIndex, session, map, player); if (entityId == null) return null; if (!session.world.isAlive(entityId)) return null; if (!session.world.getComponent(entityId, CombatComp)) return null; return entityId; } private _bindLayer(session: BattleSession, entityId: EntityId, source: string, bonusRange: number, buffId: number): void { this._layers.push({ source, bonusRange, entityId, buffId }); this._applyModifier(session, entityId, source, bonusRange); } private _hasUsedTarget(buffId: number, targetKey: string): boolean { for (let i = 0; i < this._usedTargets.length; i++) { const row = this._usedTargets[i]; if (row.buffId === buffId && row.targetKey === targetKey) return true; } return false; } private _markTargetUsed(buffId: number, targetKey: string): void { if (this._hasUsedTarget(buffId, targetKey)) return; this._usedTargets.push({ buffId, targetKey }); } private static _sourceFor(buffId: number, skillSlot: number, usageKey: string): string { return `${RANGE_SOURCE_PREFIX}b${buffId}_s${skillSlot}_${usageKey}`; } private static _findCompositionAt(player: Player | null, gridIndex: number): ComposedUnit | null { if (!player) return null; return player.compositions.find(c => c.gridIndices.includes(gridIndex)) ?? null; } private static _isMultiCellComposition(compose: ComposedUnit): boolean { WeaponComposeConfigProvider.instance.load(); const provider = WeaponComposeConfigProvider.instance; return !provider.isSingleCellWeapon1(compose.weapon1Id) && compose.gridIndices.length >= 2; } private _applyModifier(session: BattleSession, entityId: EntityId, source: string, bonusRange: number): void { const world = session.world; if (!world.isAlive(entityId)) return; if (!world.getComponent(entityId, CombatComp)) return; if (!world.getComponent(entityId, AttributeComp)) { world.addComponent(entityId, new AttributeComp()); } const attr = world.getComponent(entityId, AttributeComp); if (attr?.modifiers.some(m => m.source === source)) return; session.attributes.addModifier(entityId, { attr: AttrId.Range, op: 'Add', value: bonusRange, source, group: source, }); } } /** 获取局内千里眼宿主 */ export function getBattleGameGlobalAttackRangeHost(): BattleGameGlobalAttackRangeHost { return BattleGameGlobalAttackRangeHost.get(); }