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.
506 lines
20 KiB
506 lines
20 KiB
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 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 { TextListCellState } from '../../view/TextListLandHost';
|
|
|
|
/** 进入战斗后延迟多少秒才开始决策(落字等) */
|
|
const AI_START_DELAY_SEC = 2;
|
|
/** AI 决策 tick 间隔(秒):每拍最多一步操作 */
|
|
const AI_TICK_INTERVAL_SEC = 1;
|
|
|
|
/**
|
|
* 局内 AI 操作者(对面阵营)。
|
|
* 使用独立无头 bag,与底栏玩家拖拽无关。
|
|
*/
|
|
export class AiOpponentController {
|
|
private readonly _planner = new AiActionPlanner();
|
|
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;
|
|
/** 盘面变化后回调(技能绑定) */
|
|
private _onBoardChanged: (() => void) | null = null;
|
|
/** 多步动作排队:每拍只执行一步 */
|
|
private readonly _pendingMoves: AiMergeActionPayload[] = [];
|
|
/** DeploySync 延后对账中 */
|
|
private _deployPending = 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 = '';
|
|
|
|
/** 是否正在运行 */
|
|
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._pendingMoves.length = 0;
|
|
this._deployPending = 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._pendingMoves.length = 0;
|
|
this._deployPending = 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: null,
|
|
};
|
|
|
|
if (this._drainPendingMove()) {
|
|
if (this._flightBusy) return;
|
|
this._afterBoardMutation();
|
|
return;
|
|
}
|
|
|
|
const action = this._planner.planNext(ctx);
|
|
this._logHeartbeat(ctx, action);
|
|
if (action) {
|
|
if (this._executeAction(action)) {
|
|
if (this._flightBusy) return;
|
|
this._afterBoardMutation(action.kind);
|
|
return;
|
|
}
|
|
// 规划成功但落子拒绝:本拍回退刷新,避免整拍空转
|
|
console.warn('[AI操作] 动作失败回退刷新', action.kind);
|
|
}
|
|
|
|
// 升级/放字已穷尽:先乾坤袋回收背包多余字(场上不回收),再刷新
|
|
if (this._tryRecycleSurplus && this._tryRecycleSurplus()) {
|
|
this._afterBoardMutation();
|
|
return;
|
|
}
|
|
|
|
if (this._poolEmpty) return;
|
|
let r = this._bagHost!.tryRefresh();
|
|
// 开启格已满且无 Put:丢 1 格再召唤,否则永远合不出解锁等级
|
|
if (r === 'no_slot' && this._planner.countEmptyTextPut(ctx) <= 0) {
|
|
if (this._bagHost!.discardStuckOpenSlot()) {
|
|
r = this._bagHost!.tryRefresh();
|
|
}
|
|
}
|
|
if (r === 'pool_empty') {
|
|
this._poolEmpty = true;
|
|
console.warn('[AI操作] 字池耗尽,停止召唤');
|
|
return;
|
|
}
|
|
if (r === 'ok') {
|
|
this._afterBoardMutation();
|
|
return;
|
|
}
|
|
if (r === 'not_enough' || r === 'no_slot') {
|
|
console.warn(
|
|
'[AI操作]',
|
|
`待机 refresh=${r} gold=${this._bagHost!.aiGold}`
|
|
+ ` emptyPut=${this._planner.countEmptyTextPut(ctx)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 每拍心跳:背包填充 / 空 Put / 规划结果(始终打印,便于确认 AI 是否在动)。
|
|
* @param ctx 决策上下文
|
|
* @param action 本拍规划
|
|
*/
|
|
private _logHeartbeat(ctx: AiPlannerContext, action: AiPlannedAction | null): void {
|
|
const slots = ctx.bagSession.getBagSlots();
|
|
let filled = 0;
|
|
let openDrag = 0;
|
|
let maxLv = 0;
|
|
for (let i = 0; i < slots.length; i++) {
|
|
const s = slots[i];
|
|
if (!s) continue;
|
|
filled++;
|
|
const land = ctx.bagSession.landStateOf?.(i);
|
|
if (land == null || land === TextListCellState.Open) openDrag++;
|
|
const lv = Math.floor(Number(s.level) || 0);
|
|
if (lv > maxLv) maxLv = lv;
|
|
}
|
|
const emptyPut = this._planner.countEmptyTextPut(ctx);
|
|
const camp = AiHeadlessBagHost.AI_PLAYER_ID + 1;
|
|
const unlockId = ctx.map.areaUnlock.currentId(camp);
|
|
let detail = 'none';
|
|
if (action?.merge) {
|
|
detail = `${action.kind} ${this._fmtSrc(action.merge.source)}->${this._fmtDst(action.merge.target)}`;
|
|
} else if (action) {
|
|
detail = String(action.kind);
|
|
}
|
|
console.warn(
|
|
'[AI操作]',
|
|
`tick filled=${filled} openDrag=${openDrag} maxLv=${maxLv}`
|
|
+ ` emptyPut=${emptyPut} unlockArea=${unlockId} gold=${this._bagHost!.aiGold}`
|
|
+ ` | ${detail}`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 执行排队中的下一步 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();
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 执行单步规划动作(多步只做首步,其余入队)。
|
|
* @param action 规划结果
|
|
*/
|
|
private _executeAction(action: AiPlannedAction): boolean {
|
|
if (action.moves && action.moves.length > 0) {
|
|
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]);
|
|
}
|
|
return true;
|
|
}
|
|
if (!action.merge) return false;
|
|
if (!this._dropOne(action.merge.source, action.merge.target, String(action.kind))) {
|
|
return false;
|
|
}
|
|
if (this._flightBusy) return true;
|
|
if (action.kind === AiActionKind.Place
|
|
|| action.kind === AiActionKind.Swap
|
|
|| action.kind === AiActionKind.Upgrade) {
|
|
aiOp(
|
|
`执行 ${action.kind} ${this._fmtSrc(action.merge.source)}->${this._fmtDst(action.merge.target)}`,
|
|
);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 先尝试跳飞,成功则落地后再 dropLogical;否则立即落子。
|
|
* 解锁地块跳过跳飞,避免无头背包字块生成失败导致永远开不了地。
|
|
* @param source 源
|
|
* @param target 目标
|
|
* @param tag 动作标签
|
|
*/
|
|
private _dropOne(source: DragSource, target: DropTarget, tag: string): boolean {
|
|
if (target.kind === 'map') {
|
|
const mg = this._map?.getMapGridByIndex(target.gridIndex);
|
|
if (mg?.isAreaUnlock) {
|
|
const ok = this._applyLogicalDrop(source, target, tag);
|
|
if (ok) console.warn('[AI操作]', `解锁落子 ${this._fmtSrc(source)}->${this._fmtDst(target)}`);
|
|
else console.error('[AI操作]', `解锁落子失败 ${this._fmtSrc(source)}->${this._fmtDst(target)}`);
|
|
return ok;
|
|
}
|
|
}
|
|
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;
|
|
if (!this._running || !source || !target) {
|
|
this._clearFlightState();
|
|
return;
|
|
}
|
|
if (!ok) {
|
|
aiWarn(`跳飞失败,回落瞬移 ${this._fmtSrc(source)}->${this._fmtDst(target)}`);
|
|
}
|
|
this._clearFlightState();
|
|
if (!this._applyLogicalDrop(source, target, tag)) {
|
|
this._onBoardChanged?.();
|
|
return;
|
|
}
|
|
this._afterBoardMutation(tag === 'pending' ? undefined : tag);
|
|
};
|
|
|
|
/** 清空跳飞暂存 */
|
|
private _clearFlightState(): void {
|
|
this._flightBusy = false;
|
|
this._flightSource = null;
|
|
this._flightTarget = null;
|
|
this._flightTag = '';
|
|
}
|
|
|
|
/**
|
|
* 单次 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 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) {
|
|
bag.consumeUnlockOpened();
|
|
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 地块(仅 Upgrade;避免互换误开)
|
|
if (tag === AiActionKind.Upgrade && target.kind === 'bag') {
|
|
this._bagHost!.onBagMergedAt(target.slotIndex);
|
|
}
|
|
// 区域解锁:本侧整区 Put 对齐本机 merge(不含对侧同 areaId)
|
|
const unlockOpened = bag.consumeUnlockOpened();
|
|
for (let i = 0; i < unlockOpened.length; i++) {
|
|
const idx = unlockOpened[i];
|
|
this._syncPeerCellKind(bag, idx, bag.merge.map.getCellKind(idx));
|
|
}
|
|
// 地图格:按 AI 落子后 kind 对齐本机 merge(蟠桃 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}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
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}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 对账部署并刷新地图字表现。
|
|
* DeploySync / HeroActor 改节点延后到物理帧后,避免 Box2D UpdatePairs 崩。
|
|
*/
|
|
private _afterBoardMutation(_actionKind?: string): void {
|
|
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');
|
|
};
|
|
|
|
/**
|
|
* 清空多步队列。
|
|
* @param reason 原因
|
|
*/
|
|
private _clearMergeQueue(reason: string): void {
|
|
if (this._pendingMoves.length > 0) {
|
|
aiOp(`[AiMerge] clearQueue reason=${reason} pending=${this._pendingMoves.length}`);
|
|
}
|
|
this._pendingMoves.length = 0;
|
|
}
|
|
|
|
/** 源描述 */
|
|
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}]`;
|
|
}
|
|
}
|
|
|