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/bridge/AiBattleBridge.ts

470 lines
18 KiB

3 weeks ago
import type { Node } from 'cc';
import { aiLog, aiOp, aiWarn } from '../runtime/AiLog';
import type { ProjectBattleHandle } from '../../runtime/BattleRuntime';
import type { MapManager } from '../../map/legacy/MapManager';
import type { AiMatchProfile } from '../match/AiMatchProfile';
import type { BattleBagSession } from '../../bag/BattleBagSession';
import type { DeploySync } from '../../runtime/DeploySync';
import { AiMatchService } from '../match/AiMatchService';
2 weeks ago
import {
AiBattlePersonalityFactory,
AiTextPolicyType,
} from '../runtime/AiBattlePersonality';
3 weeks ago
import { AiHeadlessBagHost } from '../runtime/AiHeadlessBagHost';
import { AiOpponentController } from '../runtime/AiOpponentController';
import { AiHeroWeaponRegistry } from '../runtime/AiHeroWeaponRegistry';
import { AiHeroWeaponAssigner } from '../runtime/AiHeroWeaponAssigner';
import { AiSkillHost } from '../runtime/AiSkillHost';
import { AiMapTextHost } from '../runtime/AiMapTextHost';
import { AiPassiveCdHost, type AiPassiveCdUiSlot } from '../runtime/AiPassiveCdHost';
import { AiActiveSkillHost, type AiActiveSkillUiSlot, type AiActiveSkillCastFxHandler } from '../runtime/AiActiveSkillHost';
import { AiAdHost } from '../runtime/AiAdHost';
import type { AiMatchBuildInput } from '../types/AiTypes';
import { setAiHeroWeaponResolver } from '../../combat/TowerLoadoutService';
import { SkillAtkBuffBoardViewBind } from '../../combat/SkillAtkBuffBoardBridge';
import { AiMatchPlayerBuffCounts } from '../match/AiMatchPlayerBuffCounts';
import { BattleBuffCarryStore } from '../../../manager/battleBuff/BattleBuffCarryStore';
import { getBattleBuffMoveSpeedHost } from '../../combat/BattleBuffMoveSpeedHost';
import { getBattleBuffUnitAttackSpeedHost } from '../../combat/BattleBuffUnitAttackSpeedHost';
import { getBattleBuffBaseThunderHost } from '../../combat/BattleBuffBaseThunderHost';
4 days ago
import { getBattleBuffDigitStatHost } from '../../combat/BattleBuffDigitStatHost';
3 weeks ago
/** AI 模块唯一对外集成层:匹配档案 -> 局内 tick */
export class AiBattleBridge {
private static _instance: AiBattleBridge | null = null;
private readonly _matchService = new AiMatchService();
private readonly _personalityFactory = new AiBattlePersonalityFactory();
private readonly _bagHost = new AiHeadlessBagHost();
private readonly _controller = new AiOpponentController();
private readonly _weaponRegistry = new AiHeroWeaponRegistry();
private readonly _weaponAssigner = new AiHeroWeaponAssigner();
private readonly _skillHost = new AiSkillHost();
private readonly _mapText = new AiMapTextHost();
private readonly _passiveCd = new AiPassiveCdHost();
private readonly _activeSkill = new AiActiveSkillHost();
private readonly _adHost = new AiAdHost();
private _battle: ProjectBattleHandle | null = null;
private _map: MapManager | null = null;
private _profile: AiMatchProfile | null = null;
/** 单例入口 */
static get inst(): AiBattleBridge {
if (!AiBattleBridge._instance) {
AiBattleBridge._instance = new AiBattleBridge();
}
return AiBattleBridge._instance;
}
/** 匹配服务 */
get matchService(): AiMatchService {
return this._matchService;
}
/** 当前局匹配档案;未开战为 null */
get profile(): AiMatchProfile | null {
return this._profile;
}
/** AI 无头背包会话;未 bind 时为 null */
get aiBagSession(): BattleBagSession | null {
return this._bagHost.bagSession;
}
/** AI 部署同步;未 bind 时为 null */
get aiDeploySync(): DeploySync | null {
return this._bagHost.deploySync;
}
/**
* 妙手神偷:摘除 AI 地图字块视图(不销毁)。
* @param gridIndex 地图格索引
*/
detachAiMapTextGrid(gridIndex: number): import('../../view/TextGrid').TextGrid | null {
return this._mapText.detachGridForSteal(gridIndex);
}
/**
* AI 妙手神偷落地:挂接飞行字块到 AI 地图格。
* @param gridIndex 落点格
* @param grid 飞行字块
*/
attachAiStolenMapTextGrid(gridIndex: number, grid: import('../../view/TextGrid').TextGrid): void {
this._mapText.attachStolenGrid(gridIndex, grid);
}
/**
* 遍历 AI 地图托管字块。
* @param visitor 回调
*/
forEachAiMapTextGrid(visitor: (grid: import('../../view/TextGrid').TextGrid, gridIndex: number) => void): void {
this._mapText.forEachGrid(visitor);
}
/**
* 敌方地图字块点击回调(详情 / 攻击范围)。
* @param handler 点击回调;null 清除
*/
setAiMapTextClickHandler(handler: ((grid: import('../../view/TextGrid').TextGrid) => void) | null): void {
this._mapText.setMapClickHandler(handler);
}
/**
* 绑定 AI 字块跳飞层(与玩家 ui_fly 相同)。
* @param layer 飞行层
*/
setMapTextFlyLayer(layer: Node | null): void {
this._mapText.setFlyLayer(layer);
}
2 weeks ago
/**
* 绑定 AI 地图字专用层(与玩家 ui_textGrids 相同)。
* @param layer 字块层
*/
setTextGridLayer(layer: Node | null): void {
this._mapText.setTextGridLayer(layer);
}
3 weeks ago
/** AI 盘面变更后刷新地图字与技能绑定 */
notifyAiBoardChanged(): void {
this._onBoardChanged();
}
/**
* AI 主动技能 UI 快照(CD + 展示 buff)。
* @returns 槽列表;未启动为空
*/
snapshotActiveSkillUi(): AiActiveSkillUiSlot[] {
return this._activeSkill.snapshotUiSlots();
}
/**
* 注册 AI 主动施放表现(图标飞向受体)。
* @param handler 回调;null 清除
*/
setActiveSkillCastFxHandler(handler: AiActiveSkillCastFxHandler | null): void {
this._activeSkill.setCastFxHandler(handler);
}
/**
* 注册 AI 金矿飞币表现。
* @param handler 回调;null 清除
*/
setGoldMineCoinFlyHandler(handler: import('../runtime/AiPassiveCdHost').AiGoldMineCoinFlyHandler | null): void {
this._passiveCd.setGoldMineCoinFlyHandler(handler);
}
3 weeks ago
/**
* 注册 AI 妙手神偷神偷手表现。
* @param handler 回调;null 清除
*/
setStealGlyphFxHandler(handler: import('../runtime/AiActiveSkillHost').AiStealGlyphFxHandler | null): void {
this._activeSkill.setStealFxHandler(handler);
}
/**
* 注册 AI 幻魂镜表现。
* @param handler 回调;null 清除
*/
setConfuseHeroFxHandler(handler: import('../runtime/AiActiveSkillHost').AiConfuseHeroFxHandler | null): void {
this._activeSkill.setConfuseFxHandler(handler);
}
/**
* AI CD 被动 UI 快照。
* @returns 槽列表;未启动为空
*/
snapshotPassiveCdUi(): AiPassiveCdUiSlot[] {
return this._passiveCd.snapshotUiSlots();
}
/**
* 开战前构建匹配档案。
* @param input 玩家 buff 数量等;缺省读 DailyShop 携带数
*/
buildMatchProfile(input?: AiMatchBuildInput): AiMatchProfile | null {
const counts = AiMatchPlayerBuffCounts.read('匹配读携带');
const profile = this._matchService.buildProfile(input ?? {
playerActiveBuffCount: counts.active,
playerPassiveBuffCount: counts.passive,
});
this._profile = profile;
return profile;
}
/**
4 days ago
* 建波前预挂:局内三选一后赛前不再灌携带(保留空实现供旧调用兼容)。
*/
prefetchCarryForWaveBuild(): void {
4 days ago
// 局内解锁三选一获得 buff,不再赛前写入
}
3 weeks ago
/**
* 匹配页已抽出的对手写入档案(开战重抽 buff 后保留展示对手)。
* @param opponentRankId 对手段位
* @param opponentStars 对手总星
*/
applyPendingOpponent(opponentRankId: number, opponentStars: number): void {
const p = this._profile;
if (!p) return;
const rankId = Math.floor(Number(opponentRankId) || 0);
if (rankId <= 0) return;
const stars = Math.max(0, Math.floor(Number(opponentStars) || 0));
this._profile = {
playerRankId: p.playerRankId,
playerStars: p.playerStars,
opponentRankId: rankId,
opponentStars: stars,
starOffset: p.starOffset,
activeBuffCount: p.activeBuffCount,
passiveBuffCount: p.passiveBuffCount,
activeBuffIds: p.activeBuffIds,
passiveBuffIds: p.passiveBuffIds,
seed: p.seed,
};
aiOp(`锁定匹配页对手 rank=${rankId} stars=${stars}(AI buff 用开战重抽结果)`);
}
2 weeks ago
/** 是否已绑定战斗并在跑决策 */
get running(): boolean {
return !!this._battle && this._controller.running;
}
3 weeks ago
/**
* 战斗就绪后启动 AI。
* @param battle 战斗运行时句柄
* @param map 地图
* @param profile 本局档案;缺省使用已缓存 profile
* @param skillParent 挂载 AI SkillController 的 UI 节点
2 weeks ago
* @param bagLayout 玩家 ui_textList(AI 大地块槽位/邻格几何)
*/
start(battle: ProjectBattleHandle, map: MapManager, profile?: AiMatchProfile, skillParent?: Node | null, bagLayout?: Node | null): boolean {
try {
return this._startInner(battle, map, profile, skillParent, bagLayout);
} catch (e) {
console.error('[AiBattleBridge] start 异常,对手 AI 未启动', String(e));
return false;
}
}
/**
* start 主体(异常由 start 捕获)。
* @param battle 战斗
* @param map 地图
* @param profile 档案
* @param skillParent 技能父节点
* @param bagLayout 背包布局
3 weeks ago
*/
2 weeks ago
private _startInner(battle: ProjectBattleHandle, map: MapManager, profile?: AiMatchProfile, skillParent?: Node | null, bagLayout?: Node | null): boolean {
3 weeks ago
let resolved = profile ?? this._profile;
if (!resolved) {
resolved = this.buildMatchProfile();
}
if (!resolved) {
2 weeks ago
console.warn('[AiBattleBridge] profile missing,使用应急档案');
resolved = this._emergencyProfile(battle);
this._profile = resolved;
3 weeks ago
}
if (resolved.opponentRankId !== battle.opponentRankId) {
2 weeks ago
console.warn(
3 weeks ago
'[AiBattleBridge] profile.opponentRankId != battle.opponentRankId',
resolved.opponentRankId,
battle.opponentRankId,
);
}
this._mountAiBuffs(resolved, battle);
2 weeks ago
let personality = this._personalityFactory.roll(resolved.opponentRankId, resolved.seed);
3 weeks ago
if (!personality) {
2 weeks ago
console.warn('[AiBattleBridge] personality 失败,使用默认', resolved.opponentRankId);
personality = {
textPolicy: AiTextPolicyType.Type1,
};
3 weeks ago
}
2 weeks ago
if (!this._bagHost.bind(resolved, battle, map, bagLayout ?? null)) {
console.error('[AiBattleBridge] start aborted: headless bag bind failed');
3 weeks ago
return false;
}
this._weaponRegistry.clear();
this._battle = battle;
this._map = map;
this._profile = resolved;
2 weeks ago
this._mapText.setAfterFlush(this._refreshAiSkills);
3 weeks ago
if (skillParent) {
2 weeks ago
this._skillHost.bind(battle, map, skillParent, this._mapText.getMapGrids());
3 weeks ago
} else {
2 weeks ago
console.warn('[AiBattleBridge] skillParent missing: AI towers may not attack');
3 weeks ago
}
this._controller.start(
resolved,
personality,
this._bagHost,
battle,
map,
this._onBoardChanged,
this._tryRecycleSurplusBag,
this._mapText,
);
this._passiveCd.bind(this._bagHost, battle, this._onBoardChanged);
this._adHost.bind(this._bagHost, battle, map, resolved.opponentRankId, this._onBoardChanged);
this._onBoardChanged();
setAiHeroWeaponResolver(this._resolveAiWeaponForLoadout);
SkillAtkBuffBoardViewBind.bindEnemyNotify(this._onBoardChanged);
2 weeks ago
console.warn(
'[AiBattleBridge] 就绪',
'rank=' + resolved.opponentRankId,
'gold=' + this._bagHost.aiGold,
'nextCost=' + this._bagHost.nextRefreshCost,
'text=' + personality.textPolicy,
3 weeks ago
);
return true;
}
2 weeks ago
/**
* 匹配建档全失败时的最小档案(无 buff,仍可走数字合成/解锁)。
* @param battle 战斗
*/
private _emergencyProfile(battle: ProjectBattleHandle): AiMatchProfile {
const rankId = Math.max(1, Math.floor(Number(battle.opponentRankId) || Number(battle.playerRankId) || 1));
return {
playerRankId: Math.max(1, Math.floor(Number(battle.playerRankId) || 1)),
playerStars: 1,
opponentRankId: rankId,
opponentStars: 1,
starOffset: 0,
activeBuffCount: 0,
passiveBuffCount: 0,
activeBuffIds: [],
passiveBuffIds: [],
seed: (Date.now() % 100000) || 1,
};
}
3 weeks ago
/** 棋盘被外部改写后刷新 AI 字块与技能绑定 */
notifyBoardChanged(): void {
this._onBoardChanged();
}
/** 部署/落子后:刷新技能绑定 + 地图字 TextGrid */
private readonly _onBoardChanged = (): void => {
const map = this._map;
const bag = this._bagHost.bagSession;
const battle = this._battle;
if (!map || !bag || !battle) return;
const aiPlayer = battle.getPlayer(AiHeadlessBagHost.AI_PLAYER_ID) ?? null;
this._mapText.requestSync(bag, map, aiPlayer);
2 weeks ago
this._refreshAiSkills();
};
/** 字块表就绪后刷新 AI Skill 绑定 */
private readonly _refreshAiSkills = (): void => {
const map = this._map;
if (!map) return;
2 weeks ago
this._skillHost.refresh(map, this._mapText.getMapGrids());
3 weeks ago
};
/** 刷新拍:乾坤袋回收背包多余字 */
private readonly _tryRecycleSurplusBag = (): boolean => {
return this._activeSkill.tryRecycleSurplusBag();
};
/**
* 供 TowerLoadout 注册的 AI 武器解析(避免循环 require)。
* @param heroId Hero.id
*/
private readonly _resolveAiWeaponForLoadout = (heroId: number): number | null => {
return this.getHeroBattleWeaponId(heroId);
};
/** 战斗结束或切场景时停止 AI */
stop(): void {
setAiHeroWeaponResolver(null);
SkillAtkBuffBoardViewBind.bindEnemyNotify(null);
this._controller.stop();
this._passiveCd.dispose();
this._activeSkill.dispose();
this._adHost.dispose();
2 weeks ago
this._mapText.setAfterFlush(null);
3 weeks ago
this._mapText.clear();
this._skillHost.dispose();
this._bagHost.dispose();
this._weaponRegistry.clear();
BattleBuffCarryStore.clear();
this._battle = null;
this._map = null;
}
/**
4 days ago
* 局内三选一:赛前不灌匹配 buff,仅重绑 Host。
3 weeks ago
* @param profile 档案
* @param battle 战斗
*/
private _mountAiBuffs(profile: AiMatchProfile, battle: ProjectBattleHandle): void {
const aiPid = AiHeadlessBagHost.AI_PLAYER_ID;
4 days ago
BattleBuffCarryStore.setFromBuffIds(aiPid, [], []);
const gmPassive = gg.game.gmAiPassiveBuffIds;
const gmActive = gg.game.gmAiActiveBuffIds;
let seeded = 0;
if (gmActive?.length) {
for (let i = 0; i < gmActive.length; i++) {
if (BattleBuffCarryStore.appendBuffId(aiPid, gmActive[i])) seeded++;
}
}
if (gmPassive?.length) {
for (let i = 0; i < gmPassive.length; i++) {
if (BattleBuffCarryStore.appendBuffId(aiPid, gmPassive[i])) seeded++;
}
}
if (seeded > 0) {
aiOp(`GM 覆盖 AI buff 灌入 count=${seeded} profilePassive=[${profile.passiveBuffIds.join(',')}]`);
} else {
aiOp(`AI buff 赛前清空(局内解锁三选一)profilePassive=[${profile.passiveBuffIds.join(',')}]`);
}
3 weeks ago
getBattleBuffMoveSpeedHost().bind(battle);
getBattleBuffUnitAttackSpeedHost().bind(battle);
getBattleBuffBaseThunderHost().refreshCarry(battle);
4 days ago
getBattleBuffDigitStatHost().bind(battle);
3 weeks ago
}
/**
* AI 战斗武器 id(旧 aiweapon 分配已下线,非 null 时亦可能为 0)。
3 weeks ago
* @param heroId Hero.id
*/
getHeroBattleWeaponId(heroId: number): number | null {
if (!this._profile) {
aiWarn(`[AiHeroWeapon] skip: profile missing heroId=${heroId}`);
return null;
}
const record = this._weaponAssigner.resolveOrAssign(
this._weaponRegistry,
heroId,
this._profile.opponentRankId,
this._profile.seed,
);
return record != null ? record.weaponId : null;
}
/**
* 每帧 tick。
* @param dtSec 帧间隔(秒)
*/
tick(dtSec: number): void {
if (!this._battle) return;
// 结算/会话已结束:只停决策,盘面留给 UI teardown 再 dispose
if (this._battle.IsGameOver || this._battle.session.ended) {
if (this._controller.running) {
this._controller.stop();
this._passiveCd.dispose();
this._activeSkill.dispose();
this._adHost.dispose();
aiOp('局结束,停止决策');
}
return;
}
// 暂停、等复活:保留状态但不决策 / 不走 CD
if (this._battle.IsGamePause) return;
this._passiveCd.tick(dtSec);
this._adHost.tick(dtSec);
this._controller.tick(dtSec);
}
}