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.
316 lines
12 KiB
316 lines
12 KiB
import type { Node } from 'cc';
|
|
import { BattleBagSession } from '../../bag/BattleBagSession';
|
|
import { aiLog, aiOp, aiWarn } from './AiLog';
|
|
import { DeploySync } from '../../runtime/DeploySync';
|
|
import type { ProjectBattleHandle } from '../../runtime/BattleRuntime';
|
|
import type { MapManager } from '../../map/legacy/MapManager';
|
|
import type { AiMatchProfile } from '../match/AiMatchProfile';
|
|
import { AI_PLACE_CAMP, AI_PLAYER_ID, AI_VIEW_LOCAL_CAMP } from './AiCampArea';
|
|
import { WeaponComposeConfigProvider } from '../../bag/WeaponComposeConfigProvider';
|
|
import { textDisplayName } from '../../config/BattleHeroTable';
|
|
import { TextListCellState, TextListLandHost } from '../../view/TextListLandHost';
|
|
import { TableNames } from '../../../game/ConfigTableData';
|
|
|
|
/** playerId=1 无头 BattleBagSession:与玩家同套大地块 + 召唤补空 + 五行数字合成 */
|
|
export class AiHeadlessBagHost {
|
|
/** 对手 playerId */
|
|
static readonly AI_PLAYER_ID = AI_PLAYER_ID;
|
|
|
|
private _bagSession: BattleBagSession | null = null;
|
|
private _deploySync: DeploySync | null = null;
|
|
private _battle: ProjectBattleHandle | null = null;
|
|
private readonly _land = new TextListLandHost();
|
|
|
|
/** 无头背包会话;未 bind 时为 null */
|
|
get bagSession(): BattleBagSession | null {
|
|
return this._bagSession;
|
|
}
|
|
|
|
/** AI 部署同步 */
|
|
get deploySync(): DeploySync | null {
|
|
return this._deploySync;
|
|
}
|
|
|
|
/** AI 背包地块(与玩家独立进度) */
|
|
get land(): TextListLandHost {
|
|
return this._land;
|
|
}
|
|
|
|
/**
|
|
* 绑定战斗、建池、开局待开启填字(对齐玩家 autoFill)。
|
|
* @param profile 本局匹配档案
|
|
* @param battle 战斗句柄
|
|
* @param map 地图
|
|
* @param bagLayout 玩家 ui_textList(槽位数与邻格几何;不改其底图)
|
|
*/
|
|
bind(profile: AiMatchProfile, battle: ProjectBattleHandle, map: MapManager, bagLayout?: Node | null): boolean {
|
|
this.dispose();
|
|
if (!map?.gridsInitialized) {
|
|
aiLog('[AiHeadlessBagHost] bind failed: map not ready');
|
|
return false;
|
|
}
|
|
this._logCampPutGrids(map);
|
|
const levelId = battle.levelConfigId;
|
|
this._land.bindHeadless(levelId, bagLayout ?? null);
|
|
const slotCount = this._resolveSlotCount(levelId, bagLayout ?? null);
|
|
if (slotCount <= 1) {
|
|
aiWarn(`AI 背包槽数非法 slotCount=${slotCount}`);
|
|
return false;
|
|
}
|
|
const bag = new BattleBagSession(map, levelId, battle.session, slotCount, {
|
|
rankId: profile.opponentRankId,
|
|
ownerPlayerId: AiHeadlessBagHost.AI_PLAYER_ID,
|
|
});
|
|
bag.landStateOf = (slotIndex) => this._land.stateOf(slotIndex);
|
|
bag.autoFillOpenSlots();
|
|
const deploy = new DeploySync();
|
|
deploy.bind(battle, map, bag, AiHeadlessBagHost.AI_PLAYER_ID, false);
|
|
this._bagSession = bag;
|
|
this._deploySync = deploy;
|
|
this._battle = battle;
|
|
const aiPlayer = battle.getPlayer(AiHeadlessBagHost.AI_PLAYER_ID);
|
|
if (aiPlayer) {
|
|
aiPlayer.attachBagSession(bag);
|
|
} else {
|
|
console.warn('[AiHeadlessBagHost] AI Player missing, compositions query unavailable');
|
|
}
|
|
// 开局立刻召唤一次:待开启格不能拖,必须先有开启格数字才能合成/解锁地块
|
|
const summon = bag.tryFillEmptyOpenSlots(battle);
|
|
if (summon === 'ok') {
|
|
bag.applyDigitDoubleAfterRefresh(bag.lastFilledSlotIndices());
|
|
}
|
|
console.warn(
|
|
'[AiHeadlessBagHost] bound',
|
|
`aiRank=${profile.opponentRankId}`,
|
|
`gold=${battle.session.players[AiHeadlessBagHost.AI_PLAYER_ID]?.gold}`,
|
|
`slots=${slotCount}`,
|
|
`summon=${summon}`,
|
|
`placeCamp=${AI_PLACE_CAMP}`,
|
|
);
|
|
this._logBagSlots('firstFill+summon');
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 召唤:对齐玩家 tryFillEmptyOpenSlots(空开启格补字,最多 5)。
|
|
*/
|
|
tryRefresh(): 'ok' | 'not_enough' | 'failed' | 'pool_empty' | 'no_slot' {
|
|
if (!this._bagSession || !this._battle) return 'failed';
|
|
const gold = this._battle.session.players[AiHeadlessBagHost.AI_PLAYER_ID]?.gold ?? -1;
|
|
const cost = this._bagSession.normalRefreshCost;
|
|
if (gold < cost) {
|
|
return 'not_enough';
|
|
}
|
|
const result = this._bagSession.tryFillEmptyOpenSlots(this._battle);
|
|
if (result === 'ok') {
|
|
this._bagSession.applyDigitDoubleAfterRefresh(this._bagSession.lastFilledSlotIndices());
|
|
this._logBagSlots('summon');
|
|
} else if (result !== 'no_slot' && result !== 'not_enough') {
|
|
aiOp(`召唤结果 result=${result} aiGold=${gold} need=${cost}`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* 卡死时丢掉 1 个最低级开启格数字,腾出召唤空位(待开启格不丢)。
|
|
* @returns 是否丢弃成功
|
|
*/
|
|
discardStuckOpenSlot(): boolean {
|
|
const bag = this._bagSession;
|
|
if (!bag) return false;
|
|
const slots = bag.getBagSlots();
|
|
let best = -1;
|
|
let bestLv = 1e9;
|
|
for (let i = 0; i < slots.length; i++) {
|
|
const slot = slots[i];
|
|
if (!slot || !(slot.weapon2Id > 0)) continue;
|
|
if (this._land.stateOf(i) !== TextListCellState.Open) continue;
|
|
const lv = Math.floor(Number(slot.level) || 0);
|
|
if (!(lv > 0)) continue;
|
|
if (lv < bestLv || (lv === bestLv && i < best)) {
|
|
bestLv = lv;
|
|
best = i;
|
|
}
|
|
}
|
|
if (best < 0) return false;
|
|
if (!bag.discardBagSlot(best)) return false;
|
|
console.log('[AI操作]', `卡死丢弃 bag[${best}] Lv${bestLv} 腾召唤位`);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 背包数字合成落到待开启格后:推进地块并给新生待开启格补字。
|
|
* @param slotIndex 合成目标槽
|
|
*/
|
|
onBagMergedAt(slotIndex: number): void {
|
|
const bag = this._bagSession;
|
|
if (!bag) return;
|
|
if (this._land.stateOf(slotIndex) !== TextListCellState.OpenNoDrag) return;
|
|
const promoted = this._land.promoteAround(slotIndex);
|
|
bag.fillGateUnlockSlots(promoted.born, promoted.levelAdd);
|
|
if (promoted.born.length > 0) {
|
|
aiOp(`AI 待开启推进 slot=${slotIndex} born=${promoted.born.join(',')}`);
|
|
this._logBagSlots('gateUnlock');
|
|
}
|
|
}
|
|
|
|
/** 下次召唤费用(Const 4/5) */
|
|
get nextRefreshCost(): number {
|
|
return this._bagSession?.normalRefreshCost ?? -1;
|
|
}
|
|
|
|
/** AI 当前桃子 */
|
|
get aiGold(): number {
|
|
return this._battle?.session.players[AiHeadlessBagHost.AI_PLAYER_ID]?.gold ?? -1;
|
|
}
|
|
|
|
/**
|
|
* 背包参与移动后打印源目标与当前槽位。
|
|
* @param tag 动作标签
|
|
* @param source 拖拽源
|
|
* @param target 放置目标
|
|
*/
|
|
logBagMove(tag: string, source: { kind: string; slotIndex?: number; gridIndex?: number }, target: { kind: string; slotIndex?: number; gridIndex?: number }): void {
|
|
const src = source.kind === 'bag'
|
|
? `bag[${source.slotIndex}]`
|
|
: `map[${source.gridIndex}]`;
|
|
const dst = target.kind === 'bag'
|
|
? `bag[${target.slotIndex}]`
|
|
: `map[${target.gridIndex}]`;
|
|
this._logBagSlots(`${tag} ${src}->${dst}`);
|
|
}
|
|
|
|
/** 释放背包与会话引用 */
|
|
dispose(): void {
|
|
const aiPlayer = this._battle?.getPlayer(AiHeadlessBagHost.AI_PLAYER_ID);
|
|
if (aiPlayer) {
|
|
aiPlayer.attachBagSession(null);
|
|
aiPlayer.skillController = null;
|
|
}
|
|
this._deploySync?.unbind();
|
|
this._deploySync = null;
|
|
this._bagSession = null;
|
|
this._battle = null;
|
|
this._land.clear();
|
|
}
|
|
|
|
/**
|
|
* 槽位数 = max(布局格子下标, 关卡地块下标) + 1。
|
|
* @param levelId 关卡
|
|
* @param bagLayout 布局
|
|
*/
|
|
private _resolveSlotCount(levelId: number, bagLayout: Node | null): number {
|
|
let maxIndex = 0;
|
|
if (bagLayout?.isValid) {
|
|
const children = bagLayout.children;
|
|
for (let i = 0; i < children.length; i++) {
|
|
const idx = TextListLandHost.indexOfNode(children[i]);
|
|
if (idx != null && idx > maxIndex) maxIndex = idx;
|
|
}
|
|
}
|
|
const row = gg.data.table.getTableData<ITableLevel>(TableNames.Level, levelId);
|
|
if (row) {
|
|
maxIndex = Math.max(maxIndex, this._maxLandIndex(row.normalland), this._maxLandIndex(row.tolockland));
|
|
}
|
|
if (maxIndex <= 0) {
|
|
aiWarn(`无法解析 AI 背包槽数 levelId=${levelId}`);
|
|
return 0;
|
|
}
|
|
return maxIndex + 1;
|
|
}
|
|
|
|
/**
|
|
* @param raw 地块下标表
|
|
*/
|
|
private _maxLandIndex(raw: readonly number[] | undefined): number {
|
|
if (!raw?.length) return 0;
|
|
let max = 0;
|
|
for (let i = 0; i < raw.length; i++) {
|
|
const n = Math.floor(Number(raw[i]));
|
|
if (Number.isFinite(n) && n > max) max = n;
|
|
}
|
|
return max;
|
|
}
|
|
|
|
/** 打印当前 AI 背包有字槽(中文名) */
|
|
private _logBagSlots(tag: string): void {
|
|
const bag = this._bagSession;
|
|
if (!bag) {
|
|
aiOp(`背包[${tag}] session missing`);
|
|
return;
|
|
}
|
|
WeaponComposeConfigProvider.instance.load();
|
|
const slots = bag.getBagSlots();
|
|
const parts: string[] = [];
|
|
let filled = 0;
|
|
for (let i = 0; i < slots.length; i++) {
|
|
const s = slots[i];
|
|
if (!s) continue;
|
|
filled++;
|
|
const land = this._land.stateOf(i);
|
|
parts.push(`[${i}|${land}]=${this._resolveTileName(s.weapon2Id)}Lv${s.level ?? 1}`);
|
|
}
|
|
const line =
|
|
`背包[${tag}] filled=${filled}/${slots.length} gold=${this._battle?.session.players[AiHeadlessBagHost.AI_PLAYER_ID]?.gold}`
|
|
+ ` | ${parts.join(' ')}`;
|
|
// 首刷/召唤用 console,便于确认 AI 是否有可拖数字
|
|
if (tag.indexOf('firstFill') === 0 || tag === 'summon' || tag.indexOf('卡死') >= 0) {
|
|
console.warn('[AI操作]', line);
|
|
} else {
|
|
aiOp(line);
|
|
}
|
|
}
|
|
|
|
/** Text 表中文名;缺表回退 id */
|
|
private _resolveTileName(weapon2Id: number): string {
|
|
const row = WeaponComposeConfigProvider.instance.getWeapon2Config(weapon2Id);
|
|
if (!row) return `id=${weapon2Id}`;
|
|
const name = textDisplayName(row).trim();
|
|
return name || `id=${weapon2Id}`;
|
|
}
|
|
|
|
/** 开战打印各 CampArea 可放字格 / 可摆格数量 */
|
|
private _logCampPutGrids(map: MapManager): void {
|
|
const grids = map.mapGrids;
|
|
if (!Array.isArray(grids)) {
|
|
aiLog('[AiHeadlessBagHost] mapGrids missing');
|
|
return;
|
|
}
|
|
let localText = 0;
|
|
let enemyText = 0;
|
|
let localGrid = 0;
|
|
let enemyGrid = 0;
|
|
let localWalk = 0;
|
|
let enemyWalk = 0;
|
|
let dualPutDig = 0;
|
|
for (let i = 0; i < grids.length; i++) {
|
|
const mg = grids[i];
|
|
if (!mg?.node?.isValid || !mg.node.active) continue;
|
|
const camp = mg.CampAreaID | 0;
|
|
if (mg.isCanPutText && !mg.isCanWalk) {
|
|
if (camp === AI_VIEW_LOCAL_CAMP) localText++;
|
|
else if (camp === AI_PLACE_CAMP) enemyText++;
|
|
if (mg.isCanBeDig && camp === AI_PLACE_CAMP) dualPutDig++;
|
|
}
|
|
if (mg.isCanPutGrid && mg.isCanWalk) {
|
|
if (camp === AI_VIEW_LOCAL_CAMP) localGrid++;
|
|
else if (camp === AI_PLACE_CAMP) enemyGrid++;
|
|
}
|
|
if (mg.isCanWalk) {
|
|
if (camp === AI_VIEW_LOCAL_CAMP) localWalk++;
|
|
else if (camp === AI_PLACE_CAMP) enemyWalk++;
|
|
}
|
|
}
|
|
aiLog(
|
|
`[AiHeadlessBagHost] mapCamp textPut: local(1)=${localText} enemy(2)=${enemyText}`
|
|
+ ` | gridPut: local(1)=${localGrid} enemy(2)=${enemyGrid}`
|
|
+ ` | walk: local(1)=${localWalk} enemy(2)=${enemyWalk}`,
|
|
);
|
|
if (dualPutDig > 0) {
|
|
aiWarn(`地图 dig+putText 双标 enemy=${dualPutDig}(已按可放置优先当 Put)`);
|
|
}
|
|
if (enemyText <= 0) {
|
|
aiLog('[AiHeadlessBagHost] enemy CampAreaID=2 has no isCanPutText cell; AI text cannot place');
|
|
}
|
|
}
|
|
}
|
|
|