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/script/battle/ai/runtime/AiActiveSkillHost.ts

1148 lines
50 KiB

3 weeks ago
import { BattlePhase } from 'db://assets/mx/module/battle';
import type { DailyShopParsed } from '../../../manager/DailyShopTypes';
import { BattleBuffCarryStore } from '../../../manager/battleBuff/BattleBuffCarryStore';
import {
activeBuffUsesFunctionParCd,
isBattleBuffRandomActive,
readBattleBuffAttackSpeedConfig,
readBattleBuffBindNetDuration,
readBattleBuffConfuseHeroDuration,
readBattleBuffFireTrapConfig,
readBattleBuffRandomLevelConfig,
readBattleBuffRecyclePeachCount,
readBattleBuffUnitLevelDelta,
resolveBattleGameSkillDragKind,
BattleGameSkillDragKind,
} from '../../../manager/battleBuff/BattleBuffActiveSkillUtil';
import { BattleBuffFunctionType } from '../../../manager/battleBuff/BattleBuffTypes';
import { parseBattleBuffRow } from '../../../manager/BattleBuffParse';
import { TableNames } from '../../../game/ConfigTableData';
import { getBattleGameGlobalAttackSpeedHost } from '../../combat/BattleGameGlobalAttackSpeedHost';
import {
getBattleGameGlobalAttackRangeHost,
} from '../../combat/BattleGameGlobalAttackRangeHost';
import { BattleGameRandomActiveSkillHost } from '../../../ui/BattleGame/BattleGameRandomActiveSkillHost';
import { BattleGameExtraBossSkill } from '../../../ui/BattleGame/BattleGameExtraBossSkill';
import { BattleGameBindNetTrapController } from '../../../ui/BattleGame/BattleGameBindNetTrapController';
import { BattleGameFireTrapController } from '../../../ui/BattleGame/BattleGameFireTrapController';
import type { ProjectBattleHandle } from '../../runtime/BattleRuntime';
import type { MapManager } from '../../map/legacy/MapManager';
import {
buildEnemyMarchPathGridIndices,
isEnemyMonsterWalkGrid,
} from '../../map/MapPathQueries';
import { findEntityIdByGridIndex } from '../../runtime/DeployEntityQuery';
import { composeKey } from '../../runtime/ComposeUnitKey';
import { isHeroActorWeapon1 } from '../../heros/HeroActorRes';
import type { CharmTarget } from '../../mechanism/MechanismServices';
import { AbilitySetComp } from 'db://assets/mx/module/battle';
import { getTowerLoadoutService } from '../../combat/TowerLoadoutService';
import {
applyBattleBuffLevelUpViaExpFill,
applyBattleBuffRandomLevelWithExpFill,
canApplyBattleBuffLevelUpViaExpFill,
shouldBattleBuffLevelUpViaExpFill,
} from '../../combat/BattleBuffUnitLevelUpApply';
import { WeaponComposeConfigProvider } from '../../bag/WeaponComposeConfigProvider';
2 weeks ago
import { Weapon2TileType, MapCellKind, isElementGlyphType } from 'db://assets/mx/module/merge';
import { textDisplayName } from '../../config/BattleHeroTable';
3 weeks ago
import { AiActiveSkillTargetPicker, type AiActiveSkillTileTarget } from '../decision/AiActiveSkillTargetPicker';
import { aiLog, aiOp, aiWarn } from './AiLog';
import { AiHeadlessBagHost } from './AiHeadlessBagHost';
import { AI_VIEW_LOCAL_CAMP } from './AiCampArea';
import type { BattleBagSession } from '../../bag/BattleBagSession';
/** 橙字品质 */
const QUALITY_ORANGE = 4;
/** AI 主动槽 UI 快照 */
export type AiActiveSkillUiSlot = {
/** 携带 buff id */
buffId: number;
/** 展示用解析(七十二变可能为 roll 结果) */
display: DailyShopParsed;
/** CD 剩余秒 */
cdLeftSec: number;
/** CD 总秒 */
cdTotalSec: number;
};
/** AI 主动施放表现受体(与玩家拖放落点同类) */
export type AiActiveSkillCastReceptor =
| { kind: 'map'; gridIndex: number }
| { kind: 'bag'; slotIndex: number }
| { kind: 'spawn'; spawnPort: number }
| { kind: 'confuse'; target: CharmTarget }
| { kind: 'steal'; gridIndex: number; placement: { kind: 'map'; gridIndex: number } | { kind: 'bag'; slotIndex: number } };
/** AI 主动施放表现载荷 */
export type AiActiveSkillCastFxInfo = {
/** 主动槽下标(对应 otherskill1List) */
slotIndex: number;
/** 施放 buff id(七十二变后为 roll 结果) */
buffId: number;
/** 拖拽类型 */
kind: BattleGameSkillDragKind;
/** 落点受体 */
receptor: AiActiveSkillCastReceptor;
};
/** AI 主动施放表现回调 */
export type AiActiveSkillCastFxHandler = (info: AiActiveSkillCastFxInfo) => void;
/** AI 妙手神偷表现:锁定玩家字 → 抓取 → 带字飞入;成功启动返回 true */
export type AiStealGlyphFxHandler = (
playerGridIndex: number,
placement: { kind: 'map'; gridIndex: number } | { kind: 'bag'; slotIndex: number },
onExtracted: (tile: import('db://assets/mx/module/merge').MapTileRecord) => void,
) => boolean;
/** AI 幻魂镜表现:对玩家神将播施法与循环层;成功启动返回 true */
export type AiConfuseHeroFxHandler = (target: CharmTarget, durationSec: number) => boolean;
/** 单槽主动 CD 状态 */
type AiActiveSkillSlot = {
/** 携带解析(七十二变本体) */
parsed: DailyShopParsed;
/** 当前施放用解析(可能是七十二变 roll 出的) */
castParsed: DailyShopParsed;
/** CD 总秒 */
cdTotalSec: number;
/** CD 剩余 */
cdLeftSec: number;
};
/**
* AI 主动道具:CD 到就用;默认目标合成武将→普通字、等级高优先;文昌笔见 pickRewrite(§8.1)。
*/
export class AiActiveSkillHost {
private readonly _picker = new AiActiveSkillTargetPicker();
private readonly _bindNet = new BattleGameBindNetTrapController();
private readonly _fireTrap = new BattleGameFireTrapController();
private _bagHost: AiHeadlessBagHost | null = null;
private _battle: ProjectBattleHandle | null = null;
private _map: MapManager | null = null;
private _onBoardChanged: (() => void) | null = null;
private _onCastFx: AiActiveSkillCastFxHandler | null = null;
private _onStealFx: AiStealGlyphFxHandler | null = null;
private _onConfuseFx: AiConfuseHeroFxHandler | null = null;
private readonly _slots: AiActiveSkillSlot[] = [];
/** 进攻 AI 的行军路格缓存 */
private _aiMarchPath: ReturnType<typeof buildEnemyMarchPathGridIndices> | null = null;
/** 最近一次施放失败原因(供统一等待日志) */
private _castFailReason = '';
/** 各槽上次等待日志时间戳 */
private readonly _waitLogAtMs: number[] = [];
/** 各槽上次等待日志原因 */
private readonly _waitLogReason: string[] = [];
/**
* 绑定并重建主动槽。
* @param bagHost 无头背包
* @param battle 战斗
* @param map 地图
* @param onBoardChanged 盘面回调
*/
bind(bagHost: AiHeadlessBagHost, battle: ProjectBattleHandle, map: MapManager, onBoardChanged: () => void): void {
this.dispose();
this._bagHost = bagHost;
this._battle = battle;
this._map = map;
this._onBoardChanged = onBoardChanged;
this._bindNet.bind(battle, map, AiHeadlessBagHost.AI_PLAYER_ID);
this._fireTrap.bind(battle, map, AiHeadlessBagHost.AI_PLAYER_ID);
this._aiMarchPath = buildEnemyMarchPathGridIndices(map, battle.session, AiHeadlessBagHost.AI_PLAYER_ID);
this._rebuildSlots();
}
/**
* 设置施放表现回调(UI 复制图标飞向受体)。
* @param handler 回调;null 清除
*/
setCastFxHandler(handler: AiActiveSkillCastFxHandler | null): void {
this._onCastFx = handler;
}
/**
* 注册 AI 妙手神偷神偷手表现(与玩家同套抓取/带字飞)。
* @param handler 回调;null 清除
*/
setStealFxHandler(handler: AiStealGlyphFxHandler | null): void {
this._onStealFx = handler;
}
/**
* 注册 AI 幻魂镜表现(与玩家同套施法/上下层循环)。
* @param handler 回调;null 清除
*/
setConfuseFxHandler(handler: AiConfuseHeroFxHandler | null): void {
this._onConfuseFx = handler;
}
/** 解除绑定(不清除 UI 注册的施放表现回调;由 setXxxHandler(null) 显式注销) */
dispose(): void {
this._bindNet.unbind();
this._fireTrap.unbind();
this._slots.length = 0;
this._aiMarchPath = null;
this._bagHost = null;
this._battle = null;
this._map = null;
this._onBoardChanged = null;
this._castFailReason = '';
this._waitLogAtMs.length = 0;
this._waitLogReason.length = 0;
}
/**
* 帧更新:CD 满尝试施放;并 tick AI 陷阱。
* @param dtSec 帧间隔
*/
tick(dtSec: number): void {
if (!this._bagHost || !this._battle) return;
this._bindNet.tick(dtSec);
this._fireTrap.tick(dtSec);
if (this._slots.length <= 0) return;
const dt = Math.max(0, Number(dtSec) || 0);
for (let i = 0; i < this._slots.length; i++) {
this._tickSlot(this._slots[i], i, dt);
}
}
/**
* 主动槽 UI 快照(含 CD 与当前展示 buff)。
* @returns 槽列表;未绑定为空数组
*/
snapshotUiSlots(): AiActiveSkillUiSlot[] {
const out: AiActiveSkillUiSlot[] = [];
for (let i = 0; i < this._slots.length; i++) {
const slot = this._slots[i];
out.push({
buffId: slot.parsed.id,
display: slot.castParsed,
cdLeftSec: slot.cdLeftSec,
cdTotalSec: slot.cdTotalSec,
});
}
return out;
}
/** 从 CarryStore 重建主动槽 */
private _rebuildSlots(): void {
this._slots.length = 0;
const actives = BattleBuffCarryStore.getActives(AiHeadlessBagHost.AI_PLAYER_ID);
for (let i = 0; i < actives.length; i++) {
const parsed = actives[i];
const row = parsed.row as ITableBattlebuff;
const ft = Math.floor(Number(row.functiontype) || 0);
if (!activeBuffUsesFunctionParCd(ft) && !isBattleBuffRandomActive(row)) {
aiWarn(`AI 主动未识别 CD id=${parsed.id} ft=${ft} ${parsed.name}`);
continue;
}
const cdSec = Math.max(0, Math.floor(Number(parsed.cd) || 0));
this._slots.push({
parsed,
castParsed: parsed,
cdTotalSec: cdSec,
cdLeftSec: cdSec,
});
aiOp(`AI 主动槽#${this._slots.length - 1} id=${parsed.id} ${parsed.name} ft=${ft} cd=${cdSec}s`);
}
}
/**
* 刷新拍:升级/放字已穷尽后,背包仍有可回收多余字则乾坤袋收 1 格。
* 场上字永不回收;无多余字 / CD 未好返回 false。
*/
tryRecycleSurplusBag(): boolean {
if (!this._bagHost?.bagSession || !this._battle) return false;
for (let i = 0; i < this._slots.length; i++) {
const slot = this._slots[i];
if (slot.cdLeftSec > 0) continue;
if (!this._isRecycleBuff(slot.parsed)) continue;
slot.castParsed = slot.parsed;
if (!this._castRecycle(slot.parsed, i)) continue;
this._restartCd(slot);
aiOp(
`AI 主动成功 「${slot.parsed.name || '乾坤袋'}」 id=${slot.parsed.id}`
+ ` kind=乾坤袋 slot#${i} →进CD ${slot.cdTotalSec}s(刷新拍背包多余字)`,
);
this._clearWaitLog(i);
return true;
}
return false;
}
/**
* 单槽倒计时。
* @param slot 槽
* @param slotIndex 下标
* @param dt 间隔
*/
private _tickSlot(slot: AiActiveSkillSlot, slotIndex: number, dt: number): void {
if (slot.cdLeftSec > 0) {
slot.cdLeftSec = Math.max(0, slot.cdLeftSec - dt);
return;
}
// 乾坤袋不在帧 tick 自动放,仅刷新拍由 tryRecycleSurplusBag 触发
if (this._isRecycleBuff(slot.parsed)) return;
if (this._tryCast(slot, slotIndex)) {
this._restartCd(slot);
}
}
/**
* 是否为乾坤袋主动(functiontype=5 / RecycleToPeach)。
* @param parsed 解析
*/
private _isRecycleBuff(parsed: DailyShopParsed): boolean {
const row = parsed.row as ITableBattlebuff;
const kind = resolveBattleGameSkillDragKind(row);
return kind === BattleGameSkillDragKind.RecycleToPeach;
}
/**
* 尝试施放;无目标返回 false(CD 保持 0,下帧再试)。
* @param slot 槽
* @param slotIndex 下标
*/
private _tryCast(slot: AiActiveSkillSlot, slotIndex: number): boolean {
this._castFailReason = '';
const baseRow = slot.parsed.row as ITableBattlebuff;
const baseName = slot.parsed.name || `id=${slot.parsed.id}`;
if (isBattleBuffRandomActive(baseRow)) {
if (slot.castParsed === slot.parsed || isBattleBuffRandomActive(slot.castParsed.row as ITableBattlebuff)) {
const rolled = this._rollRandomActive(slotIndex);
if (!rolled) {
this._logCastWait(slotIndex, slot.parsed.id, baseName, '七十二变', 'roll池为空或无rng');
return false;
}
slot.castParsed = rolled;
aiOp(`AI 主动 七十二变 roll→「${rolled.name}」id=${rolled.id} slot#${slotIndex}`);
}
} else {
slot.castParsed = slot.parsed;
}
const cast = slot.castParsed;
const castName = cast.name || `id=${cast.id}`;
const row = cast.row as ITableBattlebuff;
const kind = resolveBattleGameSkillDragKind(row);
if (kind == null) {
aiWarn(`AI 主动无拖拽类型 「${castName}」 id=${cast.id} ft=${row.functiontype} slot#${slotIndex}`);
slot.castParsed = slot.parsed;
return false;
}
const kindTag = this._kindTag(kind);
// 无目标时每帧会进这里;尝试日志仅 verbose,避免刷屏
aiLog(`AI 主动尝试 「${castName}」 id=${cast.id} kind=${kindTag} slot#${slotIndex}`
+ (isBattleBuffRandomActive(baseRow) ? ` (自七十二变)` : ''));
let ok = false;
switch (kind) {
case BattleGameSkillDragKind.UnitLevelUp:
ok = this._castUnitLevelUp(cast, slotIndex);
break;
case BattleGameSkillDragKind.RandomLevel:
ok = this._castRandomLevel(cast, slotIndex);
break;
case BattleGameSkillDragKind.RecycleToPeach:
// 七十二变 roll 到乾坤袋:自动路径禁用,回退 roll,留给刷新拍(不打等待日志)
if (isBattleBuffRandomActive(baseRow)) {
slot.castParsed = slot.parsed;
}
aiLog(`AI 主动跳过乾坤袋(仅刷新拍回收背包多余字) slot#${slotIndex}`);
return false;
case BattleGameSkillDragKind.RewriteGlyph:
ok = this._castRewrite(cast, slotIndex);
break;
case BattleGameSkillDragKind.AttackSpeedGlobal:
ok = this._castAttackSpeed(cast, slotIndex);
break;
case BattleGameSkillDragKind.AttackRangeGlobal:
ok = this._castAttackRange(cast, slotIndex);
break;
case BattleGameSkillDragKind.ExtraBoss:
ok = this._castExtraBoss(cast, slotIndex);
break;
case BattleGameSkillDragKind.BindNet:
ok = this._castBindNet(cast, slotIndex);
break;
case BattleGameSkillDragKind.FireTrap:
ok = this._castFireTrap(cast, slotIndex);
break;
case BattleGameSkillDragKind.ConfuseHero:
ok = this._castConfuseHero(cast, slotIndex);
break;
case BattleGameSkillDragKind.StealGlyph:
ok = this._castStealGlyph(cast, slotIndex);
break;
default:
aiWarn(`AI 主动未知 kind=${kind} 「${castName}」 id=${cast.id} slot#${slotIndex}`);
slot.castParsed = slot.parsed;
ok = false;
break;
}
if (ok) {
aiOp(`AI 主动成功 「${castName}」 id=${cast.id} kind=${kindTag} slot#${slotIndex} →进CD ${slot.cdTotalSec}s`);
if (isBattleBuffRandomActive(baseRow)) {
slot.castParsed = slot.parsed;
}
this._clearWaitLog(slotIndex);
return true;
}
this._logCastWait(slotIndex, cast.id, castName, kindTag, this._castFailReason || '条件不足');
return false;
}
/**
* 记录施放失败原因并返回 false。
* @param reason 原因
*/
private _failCast(reason: string): false {
this._castFailReason = reason;
return false;
}
/**
* 等待施放日志(同原因只打一次,成功或原因变化后再打)。
* @param slotIndex 槽
* @param buffId id
* @param name 名
* @param kindTag 类型标签
* @param reason 原因
*/
private _logCastWait(slotIndex: number, buffId: number, name: string, kindTag: string, reason: string): void {
const prevReason = this._waitLogReason[slotIndex] ?? '';
if (prevReason === reason) return;
this._waitLogAtMs[slotIndex] = Date.now();
this._waitLogReason[slotIndex] = reason;
// 无合法目标属常态等待,不打操作链日志
if (reason === '无合法目标' || reason === '无可升级目标') {
aiLog(`AI 主动等待 「${name}」 id=${buffId} kind=${kindTag} slot#${slotIndex} 原因=${reason}`);
return;
}
aiOp(`AI 主动等待 「${name}」 id=${buffId} kind=${kindTag} slot#${slotIndex} 原因=${reason}`);
}
/**
* 成功施放后清除等待节流。
* @param slotIndex 槽
*/
private _clearWaitLog(slotIndex: number): void {
this._waitLogAtMs[slotIndex] = 0;
this._waitLogReason[slotIndex] = '';
}
/**
* 拖拽类型短标签(日志用)。
* @param kind 类型
*/
private _kindTag(kind: BattleGameSkillDragKind): string {
switch (kind) {
case BattleGameSkillDragKind.UnitLevelUp: return '封神榜';
case BattleGameSkillDragKind.RandomLevel: return '授仙箓';
case BattleGameSkillDragKind.RecycleToPeach: return '乾坤袋';
case BattleGameSkillDragKind.RewriteGlyph: return '文昌笔';
case BattleGameSkillDragKind.AttackSpeedGlobal: return '攻速符';
case BattleGameSkillDragKind.AttackRangeGlobal: return '千里眼';
case BattleGameSkillDragKind.ExtraBoss: return '万神幡';
case BattleGameSkillDragKind.BindNet: return '缚仙网';
case BattleGameSkillDragKind.FireTrap: return '三昧真火';
case BattleGameSkillDragKind.ConfuseHero: return '幻魂镜';
case BattleGameSkillDragKind.StealGlyph: return '妙手神偷';
default: return `kind=${kind}`;
}
}
/**
* 七十二变:随机一个可施放主动(排除全部主动槽已携带 id,含自身)。
* @param slotIndex 当前槽
*/
private _rollRandomActive(slotIndex: number): DailyShopParsed | null {
const exclude: number[] = [];
for (let i = 0; i < this._slots.length; i++) {
const id = Math.floor(Number(this._slots[i].parsed.id) || 0);
if (id > 0) exclude.push(id);
}
const pick = BattleGameRandomActiveSkillHost.pickRollBuffIdFromPool(
exclude,
this._battle?.session?.rng ?? null,
);
if (pick <= 0) {
aiWarn('AI 七十二变无可用池');
return null;
}
const row = gg.data.table.getTableData<ITableBattlebuff>(TableNames.Battlebuff, pick);
return parseBattleBuffRow(row);
}
/** 封神榜 */
private _castUnitLevelUp(parsed: DailyShopParsed, slotIndex: number): boolean {
const bag = this._bagHost?.bagSession;
const battle = this._battle;
if (!bag) return this._failCast('无AI背包');
const delta = readBattleBuffUnitLevelDelta(parsed.row as ITableBattlebuff);
if (delta <= 0) {
aiWarn(`AI 封神榜 levelDelta 无效 id=${parsed.id} delta=${delta}`);
return this._failCast(`配置无效 delta=${delta}`);
}
const target = this._picker.pick(bag, (t) => this._canLevelUp(bag, t, delta));
if (!target) return this._failCast('无可升级目标');
let ok = false;
if (target.kind === 'map' && battle && shouldBattleBuffLevelUpViaExpFill(battle, bag, target)) {
ok = applyBattleBuffLevelUpViaExpFill(battle, bag, target.gridIndex);
if (!ok) return this._failCast(`补经验失败 ${this._fmtTarget(target)}`);
aiOp(`AI 封神榜 补升级经验 ${this._fmtTarget(target)} slot#${slotIndex}`);
} else {
const reason = target.kind === 'map'
? bag.merge.applyBattleBuffUnitLevelUp(target.gridIndex, delta)
: bag.merge.applyBattleBuffUnitLevelUpBag(target.slotIndex, delta);
if (reason) return this._failCast(`升级失败 reason=${reason} ${this._fmtTarget(target)}`);
aiOp(`AI 封神榜 +${delta} ${this._fmtTarget(target)} slot#${slotIndex}`);
ok = true;
}
this._onBoardChanged?.();
this._emitCastFx(slotIndex, parsed, BattleGameSkillDragKind.UnitLevelUp, this._tileReceptor(target));
return ok;
}
/** 授仙箓 */
private _castRandomLevel(parsed: DailyShopParsed, slotIndex: number): boolean {
const bag = this._bagHost?.bagSession;
const battle = this._battle;
if (!bag || !battle) return this._failCast('无背包或战斗');
const cfg = readBattleBuffRandomLevelConfig(parsed.row as ITableBattlebuff);
if (!cfg || cfg.levelUpProbsWan.length < 4) {
aiWarn(`AI 授仙箓配置无效 id=${parsed.id}`);
return this._failCast('配置无效');
}
const target = this._picker.pick(bag, (t) => {
if (t.kind === 'map') return bag.merge.canApplyBattleBuffRandomLevel(t.gridIndex) == null;
return bag.merge.canApplyBattleBuffRandomLevelBag(t.slotIndex) == null;
});
if (!target) return this._failCast('无合法目标');
const rollWan = battle.session.rng.int(0, 9999);
const result = applyBattleBuffRandomLevelWithExpFill(
battle,
bag,
target,
cfg.levelUpProbsWan,
rollWan,
);
if (result == null) return this._failCast(`施放失败 ${this._fmtTarget(target)}`);
aiOp(`AI 授仙箓 Δ=${result} ${this._fmtTarget(target)} slot#${slotIndex}`);
this._onBoardChanged?.();
this._emitCastFx(slotIndex, parsed, BattleGameSkillDragKind.RandomLevel, this._tileReceptor(target));
return true;
}
/**
* 乾坤袋:只回收背包多余字(低等级优先);场上永不回收。
* @param parsed buff
* @param slotIndex 主动槽
*/
private _castRecycle(parsed: DailyShopParsed, slotIndex: number): boolean {
const bag = this._bagHost?.bagSession;
const battle = this._battle;
if (!bag || !battle) return this._failCast('无背包或战斗');
const peach = readBattleBuffRecyclePeachCount(parsed.row as ITableBattlebuff);
const bagSlot = this._pickSurplusBagRecycleSlot(bag);
if (bagSlot < 0) return this._failCast('背包无多余可回收字');
const target: AiActiveSkillTileTarget = { kind: 'bag', slotIndex: bagSlot };
const reason = bag.merge.applyBattleBuffRecycleBag(bagSlot);
if (reason) return this._failCast(`回收失败 reason=${reason} ${this._fmtTarget(target)}`);
if (peach > 0) {
battle.addPlayerGold(AiHeadlessBagHost.AI_PLAYER_ID, peach);
}
aiOp(`AI 乾坤袋 桃+${peach} bag[${bagSlot}] slot#${slotIndex}`);
this._onBoardChanged?.();
this._emitCastFx(slotIndex, parsed, BattleGameSkillDragKind.RecycleToPeach, this._tileReceptor(target));
return true;
}
/**
* 选背包可回收多余字:低等级 → 低品质 → 小槽下标;铲/格不可收。
* @param bag AI 背包
*/
private _pickSurplusBagRecycleSlot(bag: BattleBagSession): number {
WeaponComposeConfigProvider.instance.load();
const provider = WeaponComposeConfigProvider.instance;
const slots = bag.getBagSlots();
let best = -1;
let bestLevel = 1e9;
let bestQuality = 1e9;
for (let i = 0; i < slots.length; i++) {
const s = slots[i];
if (!s) continue;
if (bag.merge.canApplyBattleBuffRecycleBag(i) != null) continue;
const w2Type = Number(provider.getWeapon2Type(s.weapon2Id)) as Weapon2TileType;
2 weeks ago
if (!isElementGlyphType(w2Type)) continue;
3 weeks ago
const level = Math.max(1, Math.floor(Number(s.level) || 1));
const row = provider.getWeapon2Config(s.weapon2Id);
const quality = Math.max(0, Math.floor(Number(row?.quality) || 0));
if (
level < bestLevel
|| (level === bestLevel && quality < bestQuality)
|| (level === bestLevel && quality === bestQuality && (best < 0 || i < best))
) {
best = i;
bestLevel = level;
bestQuality = quality;
}
}
return best;
}
/** 文昌笔:紫字赌橙优先,其余低等级优先(§8.1) */
private _castRewrite(parsed: DailyShopParsed, slotIndex: number): boolean {
const bag = this._bagHost?.bagSession;
if (!bag) return this._failCast('无AI背包');
const target = this._picker.pickRewrite(bag, (t) => {
if (t.kind === 'map') return bag.merge.canApplyBattleBuffRewriteGlyphMap(t.gridIndex) == null;
return bag.merge.canApplyBattleBuffRewriteGlyphBag(t.slotIndex) == null;
});
if (!target) return this._failCast('无改写目标');
const excludeId = this._resolveExcludeWeapon2(bag, target);
if (excludeId == null) return this._failCast(`目标无weapon2 ${this._fmtTarget(target)}`);
const rolled = bag.rollRewriteGlyph(excludeId);
if (!rolled) return this._failCast('改写字池抽空');
const reason = target.kind === 'map'
? bag.merge.applyBattleBuffRewriteGlyphMap(target.gridIndex, rolled.weapon2Id)
: bag.merge.applyBattleBuffRewriteGlyphBag(target.slotIndex, rolled.weapon2Id);
if (reason) return this._failCast(`改写失败 reason=${reason} ${this._fmtTarget(target)}`);
aiOp(`AI 文昌笔 ->w2=${rolled.weapon2Id} ${this._fmtTarget(target)} slot#${slotIndex}`);
this._onBoardChanged?.();
this._emitCastFx(slotIndex, parsed, BattleGameSkillDragKind.RewriteGlyph, this._tileReceptor(target));
return true;
}
/**
* 攻速符:仅对已上场攻击单位施放;无目标则 CD 保持 0 等待。
* @param parsed buff
* @param slotIndex 槽
*/
private _castAttackSpeed(parsed: DailyShopParsed, slotIndex: number): boolean {
const bag = this._bagHost?.bagSession;
const battle = this._battle;
const map = this._map;
if (!bag || !battle || !map) return this._failCast('缺背包/战斗/地图');
const cfg = readBattleBuffAttackSpeedConfig(parsed.row as ITableBattlebuff);
if (!cfg || cfg.bonusWan <= 0) {
aiWarn(`AI 攻速符配置无效 id=${parsed.id}`);
return this._failCast('配置无效');
}
const buffId = parsed.id;
const player = battle.getPlayer(AiHeadlessBagHost.AI_PLAYER_ID) ?? null;
const aspd = getBattleGameGlobalAttackSpeedHost();
const target = this._picker.pick(bag, (t) => {
if (t.kind !== 'map') return false;
if (aspd.isTargetUsed(buffId, t, battle.session, player, map)) return false;
return aspd.resolveMapTargetEntityId(battle.session, player, map, t.gridIndex) != null;
});
if (!target || target.kind !== 'map') return this._failCast('无已上场可挂攻速单位');
const ok = aspd.tryActivate(battle.session, player, map, buffId, slotIndex, target, cfg.bonusWan);
if (!ok) return this._failCast(`tryActivate失败 ${this._fmtTarget(target)}`);
aiOp(`AI 攻速符 +${cfg.bonusWan} ${this._fmtTarget(target)} slot#${slotIndex}`);
this._emitCastFx(slotIndex, parsed, BattleGameSkillDragKind.AttackSpeedGlobal, this._tileReceptor(target));
return true;
}
/**
* 千里眼:仅对已上场远程单位施放;无目标则返回 false,CD 保持 0 等待。
* @param parsed buff
* @param slotIndex 槽
*/
private _castAttackRange(parsed: DailyShopParsed, slotIndex: number): boolean {
const bag = this._bagHost?.bagSession;
const battle = this._battle;
const map = this._map;
if (!bag || !battle || !map) return this._failCast('缺背包/战斗/地图');
const buffId = parsed.id;
const player = battle.getPlayer(AiHeadlessBagHost.AI_PLAYER_ID) ?? null;
const rangeHost = getBattleGameGlobalAttackRangeHost();
const target = this._picker.pick(bag, (t) => {
if (t.kind !== 'map') return false;
if (rangeHost.isTargetUsed(buffId, t, battle.session, player, map)) return false;
return rangeHost.resolveMapTargetEntityId(battle.session, player, map, t.gridIndex) != null;
});
if (!target || target.kind !== 'map') return this._failCast('无已上场远程单位');
const ok = rangeHost.tryActivate(
battle.session,
player,
map,
buffId,
slotIndex,
{ kind: 'map', gridIndex: target.gridIndex },
);
if (!ok) return this._failCast(`tryActivate失败 ${this._fmtTarget(target)}`);
aiOp(`AI 千里眼 ${this._fmtTarget(target)} slot#${slotIndex}`);
this._emitCastFx(slotIndex, parsed, BattleGameSkillDragKind.AttackRangeGlobal, this._tileReceptor(target));
return true;
}
/**
* 万神幡:从 AI 己方出怪口额外召唤 Boss 进攻玩家。
* @param parsed buff
* @param slotIndex 槽
*/
private _castExtraBoss(parsed: DailyShopParsed, slotIndex: number): boolean {
const battle = this._battle;
if (!battle) return this._failCast('无战斗');
if (battle.session.phase !== BattlePhase.COMBAT) return this._failCast('非战斗阶段');
const aiPlayerId = AiHeadlessBagHost.AI_PLAYER_ID;
const ok = BattleGameExtraBossSkill.trySummon(battle, aiPlayerId);
if (!ok) return this._failCast('召唤Boss失败(无配置/spawner)');
2 weeks ago
const spawnPort = aiPlayerId === 0 ? 2 : 1;
3 weeks ago
aiOp(`AI 万神幡 AI#${aiPlayerId} 出怪口 port=${spawnPort} slot#${slotIndex}`);
this._emitCastFx(slotIndex, parsed, BattleGameSkillDragKind.ExtraBoss, { kind: 'spawn', spawnPort });
return true;
}
/** 缚仙网:布置在进攻 AI 的怪物路径上;无格则等待 */
private _castBindNet(parsed: DailyShopParsed, slotIndex: number): boolean {
const duration = readBattleBuffBindNetDuration(parsed.row as ITableBattlebuff);
if (duration <= 0) {
aiWarn(`AI 缚仙网 duration 无效 id=${parsed.id}`);
return this._failCast('duration无效');
}
const gridIndex = this._pickTrapGrid();
if (gridIndex == null) return this._failCast('无可行军路空格');
if (!this._bindNet.tryPlace(gridIndex, duration)) return this._failCast(`布置失败 map[${gridIndex}]`);
aiOp(`AI 缚仙网 map[${gridIndex}] dur=${duration}s slot#${slotIndex}`);
this._emitCastFx(slotIndex, parsed, BattleGameSkillDragKind.BindNet, { kind: 'map', gridIndex });
return true;
}
/** 三昧真火:布置在进攻 AI 的怪物路径上;无格则等待 */
private _castFireTrap(parsed: DailyShopParsed, slotIndex: number): boolean {
const cfg = readBattleBuffFireTrapConfig(parsed.row as ITableBattlebuff);
if (!cfg || cfg.maxDamage <= 0 || cfg.durationSec <= 0) {
aiWarn(`AI 三昧真火配置无效 id=${parsed.id}`);
return this._failCast('配置无效');
}
const gridIndex = this._pickTrapGrid();
if (gridIndex == null) return this._failCast('无可行军路空格');
if (!this._fireTrap.tryPlace(gridIndex, cfg.maxDamage, cfg.durationSec)) {
return this._failCast(`布置失败 map[${gridIndex}]`);
}
aiOp(`AI 三昧真火 map[${gridIndex}] dmg=${cfg.maxDamage} dur=${cfg.durationSec}s slot#${slotIndex}`);
this._emitCastFx(slotIndex, parsed, BattleGameSkillDragKind.FireTrap, { kind: 'map', gridIndex });
return true;
}
/**
* 幻魂镜:迷惑敌方(玩家)1 名多格神将;无目标则等待。
* @param parsed buff
* @param slotIndex 槽
*/
private _castConfuseHero(parsed: DailyShopParsed, slotIndex: number): boolean {
const battle = this._battle;
const map = this._map;
if (!battle || !map) return this._failCast('缺战斗或地图');
if (battle.session.phase !== BattlePhase.COMBAT || battle.session.ended) {
return this._failCast('非战斗阶段或已结束');
}
const duration = readBattleBuffConfuseHeroDuration(parsed.row as ITableBattlebuff);
if (duration <= 0) {
aiWarn(`AI 幻魂镜 duration 无效 id=${parsed.id}`);
return this._failCast('duration无效');
}
const target = this._pickConfuseTarget();
if (!target) return this._failCast('无敌方多格神将');
if (this._onConfuseFx) {
const started = this._onConfuseFx(target, duration);
if (!started) return this._failCast('幻魂镜表现启动失败');
aiOp(`AI 幻魂镜 compose=${target.composeKey} dur=${duration}s slot#${slotIndex}`);
this._emitCastFx(slotIndex, parsed, BattleGameSkillDragKind.ConfuseHero, { kind: 'confuse', target });
return true;
}
aiWarn('AI 幻魂镜无表现回调,瞬时结算');
battle.charmController.applyCharm(target, duration, null);
aiOp(`AI 幻魂镜 compose=${target.composeKey} dur=${duration}s slot#${slotIndex}`);
this._emitCastFx(slotIndex, parsed, BattleGameSkillDragKind.ConfuseHero, { kind: 'confuse', target });
return true;
}
/**
* 妙手神偷:AI 有空位时偷玩家场上未合成武将散字或普通单字(橙可成将优先,其次紫)。
* 有表现回调时:先锁定玩家字播神偷手,抓取后再提取/飞入;无回调时瞬时结算。
* @param parsed buff
* @param slotIndex 槽
*/
private _castStealGlyph(_parsed: DailyShopParsed, slotIndex: number): boolean {
const aiBag = this._bagHost?.bagSession;
const battle = this._battle;
const map = this._map;
if (!aiBag || !battle || !map) return this._failCast('缺背包/战斗/地图');
if (aiBag.ownerPlayerId !== AiHeadlessBagHost.AI_PLAYER_ID) {
aiWarn(`AI 妙手神偷 bagSession.ownerPlayerId=${aiBag.ownerPlayerId} 非 AI,中止`);
return this._failCast(`ownerPlayerId=${aiBag.ownerPlayerId}非AI`);
}
if (battle.session.phase !== BattlePhase.COMBAT || battle.session.ended) {
return this._failCast('非战斗阶段或已结束');
}
const playerBag = battle.bagSession;
if (!playerBag) return this._failCast('无玩家背包');
if (playerBag === aiBag) {
aiWarn('AI 妙手神偷 aiBag===playerBag,中止以免写入玩家盘面');
return this._failCast('aiBag===playerBag');
}
if (!aiBag.canReceiveStolenTile()) return this._failCast('AI无空Put/背包槽');
const enemyGrid = this._pickStealTarget(aiBag, playerBag);
if (enemyGrid == null) return this._failCast('玩家放置区无可偷字块');
let placement = aiBag.resolveStealReceivePlacement();
if (!placement) return this._failCast('无接收落点');
placement = this._ensureAiStealPlacement(aiBag, map, placement);
if (!placement) return this._failCast('落点非AI阵营且背包已满');
if (playerBag.merge.canApplyStealGlyphMap(enemyGrid) != null) {
return this._failCast(`目标不可偷 map[${enemyGrid}]`);
}
const dest = placement.kind === 'map' ? `map[${placement.gridIndex}]` : `bag[${placement.slotIndex}]`;
if (this._onStealFx) {
const started = this._onStealFx(enemyGrid, placement, (tile) => {
this._logStealExtracted(aiBag, placement, enemyGrid, tile, slotIndex, false);
});
if (!started) return this._failCast('神偷手表现启动失败');
aiOp(
`AI 妙手神偷开演 fromPlayerMap[${enemyGrid}] -> AI ${dest}`
+ ` owner=${aiBag.ownerPlayerId} slot#${slotIndex}`,
);
return true;
}
aiWarn('AI 妙手神偷无神偷手回调,瞬时结算');
return this._castStealGlyphInstant(aiBag, playerBag, battle, enemyGrid, placement, slotIndex);
}
/**
* 无表现时瞬时提取并写入(测试/无 UI)。
* @param aiBag AI bag
* @param playerBag 玩家 bag
* @param battle 战斗
* @param enemyGrid 玩家源格
* @param placement 落点
* @param slotIndex 技能槽
*/
private _castStealGlyphInstant(
aiBag: NonNullable<AiHeadlessBagHost['bagSession']>,
playerBag: NonNullable<ProjectBattleHandle['bagSession']>,
battle: ProjectBattleHandle,
enemyGrid: number,
placement: { kind: 'map'; gridIndex: number } | { kind: 'bag'; slotIndex: number },
slotIndex: number,
): boolean {
const extracted = playerBag.merge.extractMapTileForSteal(enemyGrid);
if ('reason' in extracted) return this._failCast(`提取失败 reason=${extracted.reason} map[${enemyGrid}]`);
let granted = false;
if (placement.kind === 'map') {
granted = aiBag.merge.grantStolenTileOnPutGrid(placement.gridIndex, extracted.tile) == null;
if (granted) aiBag.syncMapCellVisualsFromLogic();
} else {
granted = aiBag.merge.grantStolenTileInBagSlot(placement.slotIndex, extracted.tile) == null;
}
if (!granted) {
const rollback = playerBag.merge.grantStolenTileOnPutGrid(enemyGrid, extracted.tile);
if (rollback) {
aiWarn(`AI 妙手神偷写入失败且退回失败 map[${enemyGrid}] reason=${rollback}`);
} else {
aiWarn(`AI 妙手神偷写入失败已退回 map[${enemyGrid}]`);
}
battle.deploySync?.reconcileFromMerge('aiStealGlyphRollback');
return this._failCast(`写入失败已尝试退回 map[${enemyGrid}]`);
}
battle.deploySync?.reconcileFromMerge('aiStealGlyph');
this._bagHost?.deploySync?.reconcileFromMerge('aiStealGlyph');
this._onBoardChanged?.();
this._logStealExtracted(aiBag, placement, enemyGrid, extracted.tile, slotIndex, true);
return true;
}
/**
* 妙手神偷提取成功日志。
* @param aiBag AI bag
* @param placement 落点
* @param enemyGrid 玩家源格
* @param tile 字块
* @param slotIndex 技能槽
* @param verifyBag 是否核对背包写入(仅瞬时结算,表现路径抓取时尚未 grant)
*/
private _logStealExtracted(
aiBag: NonNullable<AiHeadlessBagHost['bagSession']>,
placement: { kind: 'map'; gridIndex: number } | { kind: 'bag'; slotIndex: number },
enemyGrid: number,
tile: import('db://assets/mx/module/merge').MapTileRecord,
slotIndex: number,
verifyBag: boolean,
): void {
const w2 = tile.weapon2Id;
const lv = Math.max(1, Math.floor(Number(tile.level) || 1));
const q = this._tileQuality(w2);
WeaponComposeConfigProvider.instance.load();
const glyph = WeaponComposeConfigProvider.instance.getWeapon2Config(w2);
const name = glyph ? (textDisplayName(glyph).trim() || `id=${w2}`) : `id=${w2}`;
gg.ui.showToast(`对方使用妙手神偷,带走了你的${name}字!`);
const dest = placement.kind === 'map' ? `map[${placement.gridIndex}]` : `bag[${placement.slotIndex}]`;
aiOp(
`AI 妙手神偷 偷走「${name}」lv${lv} q=${q} id=${w2}`
+ ` fromPlayerMap[${enemyGrid}] -> AI ${dest} owner=${aiBag.ownerPlayerId} slot#${slotIndex}`,
);
if (verifyBag && placement.kind === 'bag') {
this._logAiBagAfterSteal(aiBag, placement.slotIndex, w2);
}
}
/**
* 地图落点必须在 AI CampArea;否则改 AI 背包。
* @param aiBag AI bag
* @param map 地图
* @param placement 初选落点
*/
private _ensureAiStealPlacement(
aiBag: NonNullable<AiHeadlessBagHost['bagSession']>,
map: MapManager,
placement: { kind: 'map'; gridIndex: number } | { kind: 'bag'; slotIndex: number },
): { kind: 'map'; gridIndex: number } | { kind: 'bag'; slotIndex: number } | null {
if (placement.kind === 'bag') return placement;
const aiCamp = AiHeadlessBagHost.AI_PLAYER_ID + 1;
const mg = map.getMapGridByIndex(placement.gridIndex);
if (mg && (mg.CampAreaID | 0) === aiCamp) return placement;
aiWarn(
`AI 妙手神偷地图落点非己方阵营 map[${placement.gridIndex}] camp=${mg?.CampAreaID ?? 'null'} expect=${aiCamp},改走 AI 背包`,
);
const slot = aiBag.findEmptyBagSlot();
if (slot == null) return null;
return { kind: 'bag', slotIndex: slot };
}
/**
* 偷入背包后打印 AI 槽,并检查玩家背包是否被误写。
* @param aiBag AI bag
* @param slotIndex 写入槽
* @param weapon2Id 字 id
*/
private _logAiBagAfterSteal(aiBag: NonNullable<AiHeadlessBagHost['bagSession']>, slotIndex: number, weapon2Id: number): void {
const slots = aiBag.getBagSlots();
const parts: string[] = [];
for (let i = 0; i < slots.length; i++) {
const s = slots[i];
if (!s) {
parts.push(`[${i}]=空`);
continue;
}
const row = WeaponComposeConfigProvider.instance.getWeapon2Config(s.weapon2Id);
const n = row ? (textDisplayName(row).trim() || `id=${s.weapon2Id}`) : `id=${s.weapon2Id}`;
parts.push(`[${i}]=${n}`);
}
aiOp(`AI 妙手神偷后背包 ${parts.join(' ')}`);
const got = slots[slotIndex];
if (!got || got.weapon2Id !== weapon2Id) {
aiWarn(`AI 妙手神偷后 bag[${slotIndex}] 未写入预期字 id=${weapon2Id}`);
}
const playerBag = this._battle?.bagSession;
if (!playerBag || playerBag === aiBag) return;
const pSlots = playerBag.getBagSlots();
for (let i = 0; i < pSlots.length; i++) {
const s = pSlots[i];
if (s && s.weapon2Id === weapon2Id && i === slotIndex) {
// 同槽同字可能巧合;若 AI 写入前后玩家该槽从空变有则更可疑,这里仅提示对照
aiOp(`对照:玩家 bag[${i}] 亦有同 id=${weapon2Id}(若刚被误写请查会话串线)`);
}
}
}
/**
* 在进攻 AI 的行军路上选一个可放陷阱格(无字、无已有陷阱)。
*/
private _pickTrapGrid(): number | null {
const battle = this._battle;
const map = this._map;
const aiBag = this._bagHost?.bagSession;
if (!battle || !map || !aiBag) return null;
if (!this._aiMarchPath) {
this._aiMarchPath = buildEnemyMarchPathGridIndices(map, battle.session, AiHeadlessBagHost.AI_PLAYER_ID);
}
const grids = map.mapGrids;
if (!Array.isArray(grids)) return null;
const candidates: number[] = [];
for (let i = 0; i < grids.length; i++) {
const mg = grids[i];
if (!mg?.node?.isValid || !mg.isCanWalk) continue;
if (!isEnemyMonsterWalkGrid(map, battle.session, mg, this._aiMarchPath, AiHeadlessBagHost.AI_PLAYER_ID)) {
continue;
}
if (aiBag.merge.map.hasTile(mg.Index)) continue;
if (this._bindNet.hasTrapAt(mg.Index) || this._fireTrap.hasTrapAt(mg.Index)) continue;
candidates.push(mg.Index);
}
if (candidates.length <= 0) return null;
const rng = battle.session.rng;
return candidates[rng.int(0, candidates.length - 1)];
}
/** 选敌方(玩家)可迷惑的多格神将 */
private _pickConfuseTarget(): CharmTarget | null {
const battle = this._battle;
const map = this._map;
if (!battle || !map) return null;
// AI 固定 playerId=1,幻魂镜迷惑本机神将
const enemyPlayer = battle.getPlayer(0);
if (!enemyPlayer) return null;
const comps = enemyPlayer.compositions;
let best: CharmTarget | null = null;
let bestLevel = -1;
for (let i = 0; i < comps.length; i++) {
const comp = comps[i];
if (!comp || comp.gridIndices.length < 2) continue;
if (!isHeroActorWeapon1(comp.weapon1Id)) continue;
const key = composeKey(comp);
if (battle.charmController.isComposeKeyCharmed(key)) continue;
const anchor = comp.gridIndices[0];
const entityId = findEntityIdByGridIndex(anchor, battle.session, map, enemyPlayer);
if (entityId == null) continue;
if (!battle.session.world.isAlive(entityId)) continue;
const abilities = battle.session.world.getComponent(entityId, AbilitySetComp);
if (!abilities?.abilityIds.length) continue;
if (!getTowerLoadoutService().getLoadout(entityId)?.weapon1Id) continue;
const level = Math.max(1, Math.floor(Number(comp.level) || 1));
if (level < bestLevel) continue;
bestLevel = level;
best = {
entityId,
composeKey: key,
gridIndices: comp.gridIndices.slice(),
anchorGridIndex: anchor,
};
}
return best;
}
/**
2 weeks ago
* 选玩家放置区可偷五行数字:高等级优先。
* @param _aiBag AI bag(保留签名)
3 weeks ago
* @param playerBag 玩家 bag
*/
2 weeks ago
private _pickStealTarget(_aiBag: NonNullable<AiHeadlessBagHost['bagSession']>, playerBag: NonNullable<ProjectBattleHandle['bagSession']>): number | null {
3 weeks ago
WeaponComposeConfigProvider.instance.load();
const provider = WeaponComposeConfigProvider.instance;
const map = this._map;
if (!map) return null;
2 weeks ago
type Cand = { gridIndex: number; level: number };
3 weeks ago
const cands: Cand[] = [];
playerBag.merge.map.forEachTile((gridIndex, tile) => {
const mg = map.getMapGridByIndex(gridIndex);
if (!mg || (mg.CampAreaID | 0) !== AI_VIEW_LOCAL_CAMP) return;
if (playerBag.merge.map.getCellKind(gridIndex) !== MapCellKind.Put) return;
if (playerBag.merge.canApplyStealGlyphMap(gridIndex) != null) return;
2 weeks ago
const w2Type = Number(provider.getWeapon2Type(tile.weapon2Id));
if (!isElementGlyphType(w2Type)) return;
3 weeks ago
cands.push({
gridIndex,
level: Math.max(1, Math.floor(Number(tile.level) || 1)),
});
});
if (cands.length <= 0) return null;
cands.sort((a, b) => {
if (a.level !== b.level) return b.level - a.level;
return a.gridIndex - b.gridIndex;
});
return cands[0].gridIndex;
}
/**
* Weapon2 品质。
* @param weapon2Id Text.id
*/
private _tileQuality(weapon2Id: number): number {
WeaponComposeConfigProvider.instance.load();
return Math.floor(Number(WeaponComposeConfigProvider.instance.getWeapon2Config(weapon2Id)?.quality) || 0);
}
/**
* 是否可封神榜升级。
* @param bag 会话
* @param target 落点
* @param delta 增量
*/
private _canLevelUp(bag: NonNullable<AiHeadlessBagHost['bagSession']>, target: AiActiveSkillTileTarget, delta: number): boolean {
const battle = this._battle;
if (target.kind === 'map') {
if (battle && shouldBattleBuffLevelUpViaExpFill(battle, bag, target)) {
return canApplyBattleBuffLevelUpViaExpFill(battle, bag, target.gridIndex, delta);
}
return bag.merge.canApplyBattleBuffUnitLevelUp(target.gridIndex, delta) == null;
}
return bag.merge.canApplyBattleBuffUnitLevelUpBag(target.slotIndex, delta) == null;
}
/**
* 文昌笔排除当前 weapon2Id。
* @param bag 会话
* @param target 落点
*/
private _resolveExcludeWeapon2(bag: NonNullable<AiHeadlessBagHost['bagSession']>, target: AiActiveSkillTileTarget): number | null {
if (target.kind === 'map') {
const tile = bag.merge.map.getTile(target.gridIndex);
return tile ? tile.weapon2Id : null;
}
const slot = bag.getBagSlots()[target.slotIndex];
return slot ? slot.weapon2Id : null;
}
/**
* 格式化落点日志。
* @param target 落点
*/
private _fmtTarget(target: AiActiveSkillTileTarget): string {
return target.kind === 'map' ? `map[${target.gridIndex}]` : `bag[${target.slotIndex}]`;
}
/**
* 字块落点 → 表现受体。
* @param target picker 落点
*/
private _tileReceptor(target: AiActiveSkillTileTarget): AiActiveSkillCastReceptor {
if (target.kind === 'map') return { kind: 'map', gridIndex: target.gridIndex };
return { kind: 'bag', slotIndex: target.slotIndex };
}
/**
* 通知 UI 播放主动施放表现。
* @param slotIndex 槽
* @param parsed 施放 buff
* @param kind 拖拽类型
* @param receptor 受体
*/
private _emitCastFx(
slotIndex: number,
parsed: DailyShopParsed,
kind: BattleGameSkillDragKind,
receptor: AiActiveSkillCastReceptor,
): void {
if (!this._onCastFx) return;
this._onCastFx({
slotIndex,
buffId: parsed.id,
kind,
receptor,
});
}
/**
* 重启 CD。
* @param slot 槽
*/
private _restartCd(slot: AiActiveSkillSlot): void {
slot.cdLeftSec = slot.cdTotalSec;
}
}