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

505 lines
20 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 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';
2 weeks ago
import { TextListCellState } from '../../view/TextListLandHost';
3 weeks ago
/** 进入战斗后延迟多少秒才开始决策(落字等) */
2 weeks ago
const AI_START_DELAY_SEC = 2;
3 weeks ago
/** 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;
2 weeks ago
/** 盘面变化后回调(技能绑定) */
3 weeks ago
private _onBoardChanged: (() => void) | null = null;
2 weeks ago
/** 多步动作排队:每拍只执行一步 */
3 weeks ago
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,
2 weeks ago
textFocusWeapon2Id: null,
3 weeks ago
};
if (this._drainPendingMove()) {
if (this._flightBusy) return;
this._afterBoardMutation();
return;
}
const action = this._planner.planNext(ctx);
2 weeks ago
this._logHeartbeat(ctx, action);
3 weeks ago
if (action) {
if (this._executeAction(action)) {
if (this._flightBusy) return;
this._afterBoardMutation(action.kind);
return;
}
// 规划成功但落子拒绝:本拍回退刷新,避免整拍空转
3 days ago
aiWarn('动作失败回退刷新', action.kind);
3 weeks ago
}
// 升级/放字已穷尽:先乾坤袋回收背包多余字(场上不回收),再刷新
if (this._tryRecycleSurplus && this._tryRecycleSurplus()) {
this._afterBoardMutation();
return;
}
if (this._poolEmpty) return;
2 weeks ago
let r = this._bagHost!.tryRefresh();
// 开启格已满且无 Put:丢 1 格再召唤,否则永远合不出解锁等级
if (r === 'no_slot' && this._planner.countEmptyTextPut(ctx) <= 0) {
if (this._bagHost!.discardStuckOpenSlot()) {
r = this._bagHost!.tryRefresh();
}
}
3 weeks ago
if (r === 'pool_empty') {
this._poolEmpty = true;
3 days ago
aiWarn('字池耗尽,停止召唤');
3 weeks ago
return;
}
if (r === 'ok') {
this._afterBoardMutation();
2 weeks ago
return;
}
if (r === 'not_enough' || r === 'no_slot') {
3 days ago
aiOp(
2 weeks ago
`待机 refresh=${r} gold=${this._bagHost!.aiGold}`
+ ` emptyPut=${this._planner.countEmptyTextPut(ctx)}`,
);
3 weeks ago
}
}
2 weeks ago
/**
* 每拍心跳:背包填充 / 空 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);
}
3 days ago
aiOp(
2 weeks ago
`tick filled=${filled} openDrag=${openDrag} maxLv=${maxLv}`
+ ` emptyPut=${emptyPut} unlockArea=${unlockId} gold=${this._bagHost!.aiGold}`
+ ` | ${detail}`,
);
}
3 weeks ago
/**
* 执行排队中的下一步 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;
}
/**
2 weeks ago
* 执行单步规划动作(多步只做首步,其余入队)。
3 weeks ago
* @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
2 weeks ago
|| action.kind === AiActionKind.Upgrade) {
3 weeks ago
aiOp(
`执行 ${action.kind} ${this._fmtSrc(action.merge.source)}->${this._fmtDst(action.merge.target)}`,
);
}
return true;
}
/**
* 先尝试跳飞,成功则落地后再 dropLogical;否则立即落子。
2 weeks ago
* 解锁地块跳过跳飞,避免无头背包字块生成失败导致永远开不了地。
3 weeks ago
* @param source 源
* @param target 目标
* @param tag 动作标签
*/
private _dropOne(source: DragSource, target: DropTarget, tag: string): boolean {
2 weeks ago
if (target.kind === 'map') {
const mg = this._map?.getMapGridByIndex(target.gridIndex);
if (mg?.isAreaUnlock) {
const ok = this._applyLogicalDrop(source, target, tag);
3 days ago
if (ok) aiOp(`解锁落子 ${this._fmtSrc(source)}->${this._fmtDst(target)}`);
else aiWarn(`解锁落子失败 ${this._fmtSrc(source)}->${this._fmtDst(target)}`);
2 weeks ago
return ok;
}
}
3 weeks ago
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) {
2 weeks ago
bag.consumeUnlockOpened();
aiOp(
`落子拒绝 reason=${reason} tag=${tag}`
+ ` ${this._fmtSrc(source)}->${this._fmtDst(target)}`,
);
3 weeks ago
return false;
}
if (source.kind === 'bag' || target.kind === 'bag') {
this._bagHost!.logBagMove(tag, source, target);
}
2 weeks ago
// 数字合成落到待开启格:推进 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)
3 weeks ago
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 崩。
*/
2 weeks ago
private _afterBoardMutation(_actionKind?: string): void {
this._scheduleDeferredDeploy();
3 weeks ago
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');
};
/**
2 weeks ago
* 清空多步队列。
3 weeks ago
* @param reason 原因
*/
private _clearMergeQueue(reason: string): void {
2 weeks ago
if (this._pendingMoves.length > 0) {
aiOp(`[AiMerge] clearQueue reason=${reason} pending=${this._pendingMoves.length}`);
3 weeks ago
}
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}]`;
}
}