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/AiOpponentController.ts

582 lines
23 KiB

3 weeks ago
import type { DragSource, DropTarget } from 'db://assets/mx/module/merge';
import { MapCellKind } from 'db://assets/mx/module/merge';
import { aiOp, aiWarn } from './AiLog';
import { Director, director } from 'cc';
import type { AiMatchProfile } from '../match/AiMatchProfile';
import type { AiBattlePersonality } from './AiBattlePersonality';
import { AiHeadlessBagHost } from './AiHeadlessBagHost';
import type { AiMapTextHost } from './AiMapTextHost';
import { AiActionPlanner } from '../decision/AiActionPlanner';
import { AiBoardProbe } from '../decision/AiBoardProbe';
import type { MapManager } from '../../map/legacy/MapManager';
import type { ProjectBattleHandle } from '../../runtime/BattleRuntime';
import type { AiPlannerContext, AiPlannedAction, AiMergeActionPayload } from '../types/AiTypes';
import { AiActionKind } from '../types/AiTypes';
import { WeaponComposeConfigProvider } from '../../bag/WeaponComposeConfigProvider';
import type { BattleBagSession } from '../../bag/BattleBagSession';
import { BattleBuffCarryStore } from '../../../manager/battleBuff/BattleBuffCarryStore';
import {
hasCarriedTreasureShovel,
rollCarriedTreasureShovelPeachCount,
} from '../../../manager/battleBuff/BattleBuffPassiveSkillUtil';
/** 进入战斗后延迟多少秒才开始决策(落字等) */
const AI_START_DELAY_SEC = 6;
/** AI 决策 tick 间隔(秒):每拍最多一步操作 */
const AI_TICK_INTERVAL_SEC = 1;
/**
* 局内 AI 操作者(对面阵营)。
* 使用独立无头 bag,与底栏玩家拖拽无关。
*/
export class AiOpponentController {
private readonly _planner = new AiActionPlanner();
private readonly _probe = new AiBoardProbe();
private _accumSec = 0;
/** 开战激活倒计时(秒);>0 时不决策 */
private _startDelaySec = 0;
private _running = false;
private _profile: AiMatchProfile | null = null;
private _personality: AiBattlePersonality | null = null;
private _bagHost: AiHeadlessBagHost | null = null;
private _battle: ProjectBattleHandle | null = null;
private _map: MapManager | null = null;
/** 字池耗尽后本局不再尝试刷新 */
private _poolEmpty = false;
/** 本 tick 单字冲级目标 */
private _textFocusWeapon2Id: number | null = null;
/** 盘面变化后回调(技能绑定 / 营展示) */
private _onBoardChanged: (() => void) | null = null;
/** 多步重摆/拼装排队:每拍只执行一步,避免一瞬堆字 */
private readonly _pendingMoves: AiMergeActionPayload[] = [];
/** DeploySync 延后对账中 */
private _deployPending = false;
/** 拼装/一步合成前的神将数;完成后校验是否真正成将 */
private _mergeHeroBaseline: number | null = null;
/** 当前排队是否来自合成拼装(需成将校验) */
private _pendingIsAssemble = false;
/** 刷新拍:乾坤袋回收背包多余字(场上不回收) */
private _tryRecycleSurplus: (() => boolean) | null = null;
/** AI 地图字表现(跳飞) */
private _mapText: AiMapTextHost | null = null;
/** 跳飞进行中,暂停决策 */
private _flightBusy = false;
/** 跳飞落地后要执行的逻辑落子 */
private _flightSource: DragSource | null = null;
/** 跳飞落地目标 */
private _flightTarget: DropTarget | null = null;
/** 跳飞动作标签 */
private _flightTag = '';
/** 跳飞落地后是否校验一步合成成将 */
private _flightOneStepMerge = false;
/** 是否正在运行 */
get running(): boolean {
return this._running;
}
/**
* 启动控制器。
* @param profile 匹配档案
* @param personality 整局策略
* @param bagHost 无头背包宿主
* @param battle 战斗句柄
* @param map 地图
* @param onBoardChanged 部署对账后回调
* @param tryRecycleSurplus 刷新前乾坤袋回收背包多余字
* @param mapText AI 地图字表现
*/
start(profile: AiMatchProfile, personality: AiBattlePersonality, bagHost: AiHeadlessBagHost, battle: ProjectBattleHandle, map: MapManager, onBoardChanged?: () => void, tryRecycleSurplus?: () => boolean, mapText?: AiMapTextHost | null): void {
this._profile = profile;
this._personality = personality;
this._bagHost = bagHost;
this._battle = battle;
this._map = map;
this._onBoardChanged = onBoardChanged ?? null;
this._tryRecycleSurplus = tryRecycleSurplus ?? null;
this._mapText = mapText ?? null;
this._accumSec = 0;
this._startDelaySec = AI_START_DELAY_SEC;
this._poolEmpty = false;
this._textFocusWeapon2Id = null;
this._pendingMoves.length = 0;
this._deployPending = false;
this._mergeHeroBaseline = null;
this._pendingIsAssemble = false;
this._clearFlightState();
director.off(Director.EVENT_AFTER_UPDATE, this._onDeferredDeploy);
this._running = true;
aiOp(`开战 delay=${AI_START_DELAY_SEC}s tick=${AI_TICK_INTERVAL_SEC}s`);
}
/** 停止 tick 并清理引用 */
stop(): void {
this._running = false;
this._accumSec = 0;
this._startDelaySec = 0;
this._poolEmpty = false;
this._textFocusWeapon2Id = null;
this._pendingMoves.length = 0;
this._deployPending = false;
this._mergeHeroBaseline = null;
this._pendingIsAssemble = false;
this._mapText?.abortFlight();
this._clearFlightState();
this._mapText = null;
director.off(Director.EVENT_AFTER_UPDATE, this._onDeferredDeploy);
this._onBoardChanged = null;
this._tryRecycleSurplus = null;
this._profile = null;
this._personality = null;
this._bagHost = null;
this._battle = null;
this._map = null;
}
/**
* 由桥接层每帧调用;开战延迟结束后按固定节拍触发决策。
* @param dtSec 帧间隔(秒)
*/
tick(dtSec: number): void {
if (!this._running || !this._profile || !this._personality || !this._bagHost) return;
if (this._flightBusy) return;
if (this._startDelaySec > 0) {
this._startDelaySec -= dtSec;
if (this._startDelaySec > 0) return;
this._startDelaySec = 0;
this._accumSec = 0;
aiOp(`已激活 tick=${AI_TICK_INTERVAL_SEC}s`);
this._runOneTick();
return;
}
this._accumSec += dtSec;
if (this._accumSec < AI_TICK_INTERVAL_SEC) return;
// 卡顿追帧时也不连放多拍,每帧最多一步
this._accumSec = 0;
this._runOneTick();
}
/**
* 单次节拍:优先消化排队重摆 → 否则一步 §6.1 动作 → 否则有桃刷一次。
*/
private _runOneTick(): void {
const bagSession = this._bagHost!.bagSession;
if (!bagSession || !this._map || !this._profile || !this._personality) return;
WeaponComposeConfigProvider.instance.load();
const ctx: AiPlannerContext = {
playerId: AiHeadlessBagHost.AI_PLAYER_ID,
profile: this._profile,
personality: this._personality,
bagSession,
map: this._map,
textFocusWeapon2Id: this._textFocusWeapon2Id,
};
this._planner.textPlanner.refreshFocus(ctx);
this._textFocusWeapon2Id = ctx.textFocusWeapon2Id;
// 排队中的拼装/重摆:每拍只走一步,保留 1s 停顿感
if (this._drainPendingMove()) {
if (this._flightBusy) return;
this._afterBoardMutation();
return;
}
const action = this._planner.planNext(ctx);
if (action) {
if (this._executeAction(action)) {
if (this._flightBusy) return;
this._afterBoardMutation(action.kind);
return;
}
// 规划成功但落子拒绝:本拍回退刷新,避免整拍空转
aiOp(`动作失败回退刷新 kind=${action.kind}`);
}
// 升级/放字已穷尽:先乾坤袋回收背包多余字(场上不回收),再刷新
if (this._tryRecycleSurplus && this._tryRecycleSurplus()) {
this._afterBoardMutation();
return;
}
if (this._poolEmpty) return;
const r = this._bagHost!.tryRefresh();
if (r === 'pool_empty') {
this._poolEmpty = true;
return;
}
if (r === 'ok') {
this._afterBoardMutation();
}
}
/**
* 执行排队中的下一步 map→map;无排队返回 false。
* 失败不消费该步,清空队列避免半成品长期空转。
*/
private _drainPendingMove(): boolean {
if (this._pendingMoves.length <= 0) return false;
const step = this._pendingMoves[0];
if (!this._dropOne(step.source, step.target, 'pending')) {
aiOp(
`[AiMerge] pending drop fail left=${this._pendingMoves.length}`
+ ` src=${this._fmtSrc(step.source)} dst=${this._fmtDst(step.target)}`,
);
this._clearMergeQueue('pending_drop_fail');
return false;
}
this._pendingMoves.shift();
if (this._flightBusy) return true;
if (this._pendingMoves.length <= 0 && this._pendingIsAssemble) {
this._verifyMergeHeroGain('assemble_done');
}
return true;
}
/**
* 执行单步规划动作(多步重摆/拼装只做首步,其余入队)。
* @param action 规划结果
*/
private _executeAction(action: AiPlannedAction): boolean {
if (action.moves && action.moves.length > 0) {
const isAssemble = action.kind === AiActionKind.Merge;
if (isAssemble) {
this._mergeHeroBaseline = this._countComboHeroes();
this._pendingIsAssemble = true;
aiOp(
`[AiMerge] assemble start moves=${action.moves.length} heroesBefore=${this._mergeHeroBaseline}`,
);
} else {
this._pendingIsAssemble = false;
this._mergeHeroBaseline = null;
}
const first = action.moves[0];
if (!this._dropOne(first.source, first.target, String(action.kind))) {
aiOp(
`[AiMerge] first drop fail kind=${action.kind}`
+ ` src=${this._fmtSrc(first.source)} dst=${this._fmtDst(first.target)}`,
);
this._clearMergeQueue('first_drop_fail');
return false;
}
this._pendingMoves.length = 0;
for (let i = 1; i < action.moves.length; i++) {
this._pendingMoves.push(action.moves[i]);
}
if (this._flightBusy) return true;
if (this._pendingMoves.length <= 0 && isAssemble) {
this._verifyMergeHeroGain('assemble_one_step');
}
return true;
}
if (!action.merge) return false;
const isOneStepMerge = action.kind === AiActionKind.Merge;
const before = isOneStepMerge ? this._countComboHeroes() : -1;
if (isOneStepMerge) {
this._mergeHeroBaseline = before;
this._flightOneStepMerge = true;
aiOp(
`[AiMerge] one-step start heroesBefore=${before}`
+ ` src=${this._fmtSrc(action.merge.source)} dst=${this._fmtDst(action.merge.target)}`,
);
} else {
this._flightOneStepMerge = false;
}
if (!this._dropOne(action.merge.source, action.merge.target, String(action.kind))) {
this._flightOneStepMerge = false;
if (isOneStepMerge) {
aiOp('[AiMerge] one-step drop fail');
}
return false;
}
if (this._flightBusy) return true;
if (action.kind === AiActionKind.Place
|| action.kind === AiActionKind.Swap
|| action.kind === AiActionKind.Upgrade
|| action.kind === AiActionKind.TextPolicy
|| action.kind === AiActionKind.Shovel) {
aiOp(
`执行 ${action.kind} ${this._fmtSrc(action.merge.source)}->${this._fmtDst(action.merge.target)}`,
);
}
if (isOneStepMerge) {
this._verifyMergeHeroGain('one_step');
}
return true;
}
/**
* 先尝试跳飞,成功则落地后再 dropLogical;否则立即落子。
* @param source 源
* @param target 目标
* @param tag 动作标签
*/
private _dropOne(source: DragSource, target: DropTarget, tag: string): boolean {
const mapText = this._mapText;
if (mapText && mapText.tryPlayDropFlight(source, target, this._onDropFlightDone)) {
this._flightBusy = true;
this._flightSource = source;
this._flightTarget = target;
this._flightTag = tag;
return true;
}
return this._applyLogicalDrop(source, target, tag);
}
/** 跳飞落地:逻辑落子并对账 */
private readonly _onDropFlightDone = (ok: boolean): void => {
const source = this._flightSource;
const target = this._flightTarget;
const tag = this._flightTag;
const oneStep = this._flightOneStepMerge;
if (!this._running || !source || !target) {
this._mapText?.abortShovelDig();
this._clearFlightState();
return;
}
if (!ok) {
aiWarn(`跳飞失败,回落瞬移 ${this._fmtSrc(source)}->${this._fmtDst(target)}`);
}
if (ok && tag === AiActionKind.Shovel && target.kind === 'map') {
this._bagHost?.bagSession?.deferOpenEffectForGrid(target.gridIndex);
if (!this._applyLogicalDrop(source, target, tag)) {
this._mapText?.abortShovelDig();
this._clearFlightState();
this._onBoardChanged?.();
return;
}
if (this._mapText?.playShovelDig(target.gridIndex, this._onShovelDigDone)) {
return;
}
this._map?.getMapGridByIndex(target.gridIndex)?.repairShovelDigVisual();
this._clearFlightState();
this._afterBoardMutation(AiActionKind.Shovel);
return;
}
this._clearFlightState();
if (!this._applyLogicalDrop(source, target, tag)) {
this._onBoardChanged?.();
if (oneStep) aiOp('[AiMerge] one-step drop fail');
return;
}
this._afterBoardMutation(tag === 'pending' ? undefined : tag);
if (this._pendingMoves.length <= 0 && this._pendingIsAssemble) {
this._verifyMergeHeroGain('assemble_done');
} else if (oneStep) {
this._verifyMergeHeroGain('one_step');
}
};
/** 铲开挖演出结束:对账盘面 */
private readonly _onShovelDigDone = (_ok: boolean): void => {
this._clearFlightState();
if (!this._running) return;
this._afterBoardMutation(AiActionKind.Shovel);
};
/** 清空跳飞暂存 */
private _clearFlightState(): void {
this._flightBusy = false;
this._flightSource = null;
this._flightTarget = null;
this._flightTag = '';
this._flightOneStepMerge = false;
}
/**
* 单次 dropLogical;涉及背包时由 Headless 打槽位日志。
* @param source 源
* @param target 目标
* @param tag 动作标签
*/
private _applyLogicalDrop(source: DragSource, target: DropTarget, tag: string): boolean {
const bag = this._bagHost?.bagSession;
if (!bag) return false;
const isShovel = tag === AiActionKind.Shovel && target.kind === 'map';
const targetBefore = target.kind === 'map' ? bag.merge.map.getCellKind(target.gridIndex) : null;
const targetPeerBefore = target.kind === 'map' ? this._peerCellKind(bag, target.gridIndex) : null;
const reason = bag.dropLogical(source, target);
if (reason) {
if (isShovel) {
aiOp(`铲子落子拒绝 reason=${reason} map[${target.gridIndex}]`);
} else {
aiOp(
`落子拒绝 reason=${reason} tag=${tag}`
+ ` ${this._fmtSrc(source)}->${this._fmtDst(target)}`,
);
}
return false;
}
if (source.kind === 'bag' || target.kind === 'bag') {
this._bagHost!.logBagMove(tag, source, target);
}
// 地图格:按 AI 落子后 kind 对齐本机 merge(铲 Dig→Put;蟠桃 Put→Dig / 保持 Dig)
if (source.kind === 'map') {
this._syncPeerCellKind(bag, source.gridIndex, bag.merge.map.getCellKind(source.gridIndex));
}
if (target.kind === 'map') {
const afterKind = bag.merge.map.getCellKind(target.gridIndex);
this._syncPeerCellKind(bag, target.gridIndex, afterKind);
const tile = bag.merge.map.getTile(target.gridIndex);
const keepDig = !!tile
&& WeaponComposeConfigProvider.instance.passiveSpawnKeepsDigCell(tile.weapon2Id);
if (keepDig) {
const peerAfter = this._peerCellKind(bag, target.gridIndex);
aiOp(
`[蟠桃同步] drop tag=${tag} map[${target.gridIndex}]`
+ ` before=${targetBefore} after=${afterKind}`
+ ` peerBefore=${targetPeerBefore} peerAfter=${peerAfter}`
+ ` w2=${tile?.weapon2Id ?? 0} lv=${tile?.level ?? 0}`,
);
if (afterKind !== MapCellKind.Dig || peerAfter !== MapCellKind.Dig) {
aiWarn(
`[蟠桃同步] drop 后应为 Dig map[${target.gridIndex}]`
+ ` after=${afterKind} peer=${peerAfter}`,
);
}
}
}
if (isShovel) {
this._tryGrantTreasureShovelPeach();
}
this._warnPeachPeerMismatch(`afterDrop:${tag}`);
return true;
}
/**
* 读取本机 bag 格 kind。
* @param bag AI bag
* @param gridIndex 格
*/
private _peerCellKind(bag: BattleBagSession, gridIndex: number): MapCellKind | null {
const peer = this._battle?.bagSession;
if (!peer || peer === bag) return null;
return peer.merge.map.getCellKind(gridIndex);
}
/**
* 对齐本机 merge 格语义到 AI 落子后 kind。
* @param bag AI bag
* @param gridIndex 格
* @param kind 落子后 kind
*/
private _syncPeerCellKind(bag: BattleBagSession, gridIndex: number, kind: MapCellKind | null): void {
const peer = this._battle?.bagSession;
if (!peer || peer === bag) return;
bag.alignPeerCellKind(peer, gridIndex, kind);
}
/**
* 扫描 AI 盘面蟠桃:peer kind 必须为 Dig;不一致则 warn(便于复制日志)。
* @param tag 触发点
*/
private _warnPeachPeerMismatch(tag: string): void {
const bag = this._bagHost?.bagSession;
const peer = this._battle?.bagSession;
if (!bag || !peer || peer === bag) return;
WeaponComposeConfigProvider.instance.load();
const provider = WeaponComposeConfigProvider.instance;
const indices = bag.merge.map.gridIndices;
for (let i = 0; i < indices.length; i++) {
const gridIndex = indices[i];
const tile = bag.merge.map.getTile(gridIndex);
if (!tile || !provider.passiveSpawnKeepsDigCell(tile.weapon2Id)) continue;
const aiKind = bag.merge.map.getCellKind(gridIndex);
const peerKind = peer.merge.map.getCellKind(gridIndex);
if (aiKind === MapCellKind.Dig && peerKind === MapCellKind.Dig) continue;
aiWarn(
`[蟠桃同步] 不一致 ${tag} map[${gridIndex}]`
+ ` ai=${aiKind} peer=${peerKind} w2=${tile.weapon2Id} lv=${tile.level ?? 1}`,
);
}
}
/** 掘宝仙铲:AI 挖开 Dig 格时按 CarryStore 掷桃入 AI 槽 */
private _tryGrantTreasureShovelPeach(): void {
const battle = this._battle;
if (!battle) return;
const aiPid = AiHeadlessBagHost.AI_PLAYER_ID;
const passives = BattleBuffCarryStore.getPassives(aiPid);
if (!hasCarriedTreasureShovel(passives)) return;
const peach = rollCarriedTreasureShovelPeachCount(passives);
if (peach <= 0) return;
battle.addPlayerGold(aiPid, peach);
aiOp(`掘宝仙铲产桃 +${peach} -> player${aiPid}`);
}
/**
* 对账部署并刷新地图字表现。
* DeploySync / HeroActor 改节点延后到物理帧后,避免 Box2D UpdatePairs 崩。
* @param actionKind 动作种类;铲不改合成态,跳过 DeploySync
*/
private _afterBoardMutation(actionKind?: string): void {
if (actionKind !== AiActionKind.Shovel) {
this._scheduleDeferredDeploy();
}
this._onBoardChanged?.();
}
/** 排队延后 DeploySync(合并同帧多次落子) */
private _scheduleDeferredDeploy(): void {
if (this._deployPending) return;
this._deployPending = true;
director.once(Director.EVENT_AFTER_UPDATE, this._onDeferredDeploy);
}
private readonly _onDeferredDeploy = (): void => {
this._deployPending = false;
if (!this._running) return;
this._bagHost?.deploySync?.reconcileFromMerge('aiTick');
};
/** 当前 AI 场上合成武将数 */
private _countComboHeroes(): number {
const bag = this._bagHost?.bagSession;
if (!bag) return 0;
return this._probe.countComboHeroes(bag);
}
/**
* 校验合成是否真正增加神将;始终打印。
* @param tag 阶段
*/
private _verifyMergeHeroGain(tag: string): void {
if (this._mergeHeroBaseline == null) return;
const before = this._mergeHeroBaseline;
const after = this._countComboHeroes();
this._mergeHeroBaseline = null;
this._pendingIsAssemble = false;
if (after > before) {
aiOp(`[AiMerge] ok ${tag} heroes ${before}->${after}`);
} else {
aiOp(`[AiMerge] FAIL ${tag} heroes still=${after} (before=${before}) 未成将`);
}
}
/**
* 清空拼装/重摆队列。
* @param reason 原因
*/
private _clearMergeQueue(reason: string): void {
if (this._pendingMoves.length > 0 || this._mergeHeroBaseline != null) {
aiOp(
`[AiMerge] clearQueue reason=${reason}`
+ ` pending=${this._pendingMoves.length} baseline=${this._mergeHeroBaseline}`,
);
}
this._pendingMoves.length = 0;
this._mergeHeroBaseline = null;
this._pendingIsAssemble = false;
}
/** 源描述 */
private _fmtSrc(source: DragSource): string {
return source.kind === 'bag' ? `bag[${source.slotIndex}]` : `map[${source.gridIndex}]`;
}
/** 目标描述 */
private _fmtDst(target: DropTarget): string {
return target.kind === 'bag' ? `bag[${target.slotIndex}]` : `map[${target.gridIndex}]`;
}
}