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/decision/AiPlacementScorer.ts

269 lines
11 KiB

import { MapCellKind, Weapon2TileType, isElementGlyphType } from 'db://assets/mx/module/merge';
import type { AiPlannerContext, AiPlannedAction, AiPlaceActionPayload } from '../types/AiTypes';
import { AiActionKind } from '../types/AiTypes';
import { AiBoardProbe } from './AiBoardProbe';
import { WeaponComposeConfigProvider } from '../../bag/WeaponComposeConfigProvider';
import { BattleMapSpec } from '../../map/BattleMapSpec';
import type { MapGrid } from '../../map/legacy/MapGrid';
import { TextListCellState } from '../../view/TextListLandHost';
import { aiOp, aiWarn } from '../runtime/AiLog';
/** 落空 Put 候选评分 */
interface PlaceCand {
/** 地图格索引 */
gridIndex: number;
/** 行 */
row: number;
/** 列 */
col: number;
/** 落子后预估武将数 */
heroes: number;
/** 阵型分 */
form: number;
}
/**
* 五行数字落空 Put 评分。
*/
export class AiPlacementScorer {
private readonly _probe = new AiBoardProbe();
/**
* 为背包某槽选最优空位并生成落子动作。
* @param ctx 决策上下文
* @param slotIndex 背包槽
*/
planPlaceSlot(ctx: AiPlannerContext, slotIndex: number): AiPlannedAction | null {
const slot = ctx.bagSession.getBagSlots()[slotIndex];
if (!slot) return null;
const land = ctx.bagSession.landStateOf?.(slotIndex);
if (land != null && land !== TextListCellState.Open) return null;
WeaponComposeConfigProvider.instance.load();
const provider = WeaponComposeConfigProvider.instance;
if (provider.passiveSpawnKeepsDigCell(slot.weapon2Id)) {
return this._planKeepDigPlace(ctx, slotIndex, slot);
}
const w2Type = Number(provider.getWeapon2Type(slot.weapon2Id)) as Weapon2TileType;
if (!isElementGlyphType(w2Type)) return null;
const level = Math.floor(slot.level ?? 1);
const w1 = provider.getElementHeroId(w2Type, level) ?? 0;
const heroCountBase = this._probe.predictHeroCountAfterPlace(ctx.bagSession, slot.weapon2Id);
const grids = ctx.map.mapGrids;
if (!Array.isArray(grids)) return null;
const best = this._pickBestEmptyPut(ctx, grids, w2Type, w1, heroCountBase);
if (!best) return null;
const place: AiPlaceActionPayload = {
weapon1Id: w1,
level,
row: best.row,
col: best.col,
};
aiOp(
`摆放规划 bag[${slotIndex}] w2=${slot.weapon2Id} type=${w2Type}`
+ ` -> map[${best.gridIndex}] rc=(${best.row},${best.col})`
+ ` form=${Math.round(best.form)} heroes=${best.heroes}`,
);
return {
kind: AiActionKind.Place,
merge: {
source: { kind: 'bag', slotIndex },
target: { kind: 'map', gridIndex: best.gridIndex },
},
place,
};
}
/**
* 为待落子单位选最优格(兼容旧接口)。
* @param ctx 决策上下文
* @param weapon1Id Weapon1 id
* @param level 局内等级
*/
pickBest(ctx: AiPlannerContext, weapon1Id: number, level: number): AiPlaceActionPayload | null {
void level;
const grids = ctx.map.mapGrids;
if (!Array.isArray(grids)) return null;
let best: AiPlaceActionPayload | null = null;
let bestForm = -1e99;
let bestIndex = 1e9;
for (let i = 0; i < grids.length; i++) {
const mg = grids[i];
if (!mg?.node?.isValid || !mg.node.active) continue;
if (!ctx.bagSession.validator.isAllowedCampForPlace(mg.Index)) continue;
if (ctx.bagSession.merge.map.getCellKind(mg.Index) !== MapCellKind.Put) continue;
if (ctx.bagSession.merge.map.hasTile(mg.Index)) continue;
const form = this._probe.formationScore(ctx.map, mg, weapon1Id);
if (form > bestForm || (form === bestForm && mg.Index < bestIndex)) {
bestForm = form;
bestIndex = mg.Index;
best = { weapon1Id, level: level, row: mg.Row, col: mg.Col };
}
}
return best;
}
/**
* 扫描背包五行字落空 Put。
* @param ctx 决策上下文
*/
plan(ctx: AiPlannerContext): AiPlannedAction | null {
WeaponComposeConfigProvider.instance.load();
const slots = ctx.bagSession.getBagSlots();
const order: number[] = [];
for (let i = 0; i < slots.length; i++) {
if (!slots[i]) continue;
const w2Type = Number(WeaponComposeConfigProvider.instance.getWeapon2Type(slots[i]!.weapon2Id));
if (!isElementGlyphType(w2Type)
&& !WeaponComposeConfigProvider.instance.passiveSpawnKeepsDigCell(slots[i]!.weapon2Id)) {
continue;
}
order.push(i);
}
const emptyPut = this._probe.countEmptyTextPut(ctx.bagSession, ctx.map);
this._sortPlaceOrder(ctx, order);
if (order.length > 0) {
const names: string[] = [];
for (let i = 0; i < order.length; i++) {
const s = slots[order[i]];
names.push(s ? `bag[${order[i]}]=${s.weapon2Id}` : `bag[${order[i]}]=?`);
}
aiOp(`摆放扫描 emptyPut=${emptyPut} order=${names.join(',')}`);
}
for (let i = 0; i < order.length; i++) {
const action = this.planPlaceSlot(ctx, order[i]);
if (action) return action;
}
if (emptyPut > 0 && order.length > 0) {
aiWarn(`摆放失败 emptyPut=${emptyPut} bagSlots=${order.length} 无候选(空格均不可落)`);
}
return null;
}
/**
* 在可落空 Put 中选最优格。
* @param ctx 决策上下文
* @param grids 地图格
* @param w2Type 字类型
* @param w1 阵型用 weapon1
* @param heroCountBase 落子后预估武将数
*/
private _pickBestEmptyPut(ctx: AiPlannerContext, grids: readonly MapGrid[], w2Type: Weapon2TileType, w1: number, heroCountBase: number): PlaceCand | null {
let best: PlaceCand | null = null;
for (let i = 0; i < grids.length; i++) {
const mg = grids[i];
if (!mg?.node?.isValid || !mg.node.active) continue;
if (!ctx.bagSession.validator.isAllowedCampForPlace(mg.Index)) continue;
if (!this._probe.canDropTextOnEmptyGrid(ctx.bagSession, mg, w2Type)) continue;
const form = this._probe.formationScore(ctx.map, mg, w1);
const cand: PlaceCand = { gridIndex: mg.Index, row: mg.Row, col: mg.Col, heroes: heroCountBase, form };
best = this._betterPlaceCand(best, cand);
}
return best;
}
/**
* 比较落点候选:武将数 → 阵型 → 更小 gridIndex。
* @param best 当前最优
* @param cand 新候选
*/
private _betterPlaceCand(best: PlaceCand | null, cand: PlaceCand): PlaceCand {
if (!best
|| cand.heroes > best.heroes
|| (cand.heroes === best.heroes && cand.form > best.form)
|| (cand.heroes === best.heroes && cand.form === best.form && cand.gridIndex < best.gridIndex)) {
return cand;
}
return best;
}
/**
* 落子扫描序(高等级优先)。
* @param ctx 上下文
* @param order 槽下标
*/
private _sortPlaceOrder(ctx: AiPlannerContext, order: number[]): void {
const provider = WeaponComposeConfigProvider.instance;
const slots = ctx.bagSession.getBagSlots();
for (let i = 0; i < order.length; i++) {
for (let j = i + 1; j < order.length; j++) {
const a = order[i];
const b = order[j];
if (this._placePriority(ctx, slots[b]!, provider) <= this._placePriority(ctx, slots[a]!, provider)) {
continue;
}
order[i] = b;
order[j] = a;
}
}
}
/**
* 蟠桃树落子:优先空 Dig,否则空 Put。
* @param ctx 决策上下文
* @param slotIndex 背包槽
* @param slot 背包字
*/
private _planKeepDigPlace(ctx: AiPlannerContext, slotIndex: number, slot: { weapon2Id: number; level?: number }): AiPlannedAction | null {
const grids = ctx.map.mapGrids;
if (!Array.isArray(grids)) return null;
let bestDig: { gridIndex: number; row: number; col: number } | null = null;
let bestPut: { gridIndex: number; row: number; col: number } | null = null;
for (let i = 0; i < grids.length; i++) {
const mg = grids[i];
if (!mg?.node?.isValid || !mg.node.active) continue;
if (!this._probe.canDropKeepDigOnEmptyGrid(ctx.bagSession, mg)) continue;
const kind = ctx.bagSession.merge.map.getCellKind(mg.Index);
const pick = { gridIndex: mg.Index, row: mg.Row, col: mg.Col };
if (kind === MapCellKind.Dig) {
if (!bestDig || mg.Index < bestDig.gridIndex) bestDig = pick;
} else if (kind === MapCellKind.Put) {
if (!bestPut || mg.Index < bestPut.gridIndex) bestPut = pick;
}
}
const best = bestDig ?? bestPut;
if (!best) return null;
const kind = ctx.bagSession.merge.map.getCellKind(best.gridIndex);
aiOp(
`摆放规划(蟠桃树) bag[${slotIndex}] w2=${slot.weapon2Id}`
+ ` -> map[${best.gridIndex}] rc=(${best.row},${best.col}) kind=${kind}`,
);
return {
kind: AiActionKind.Place,
merge: {
source: { kind: 'bag', slotIndex },
target: { kind: 'map', gridIndex: best.gridIndex },
},
place: {
weapon1Id: this._probe.resolveWeapon1Id(slot) ?? 0,
level: slot.level ?? 1,
row: best.row,
col: best.col,
},
};
}
/**
* 单槽落子优先级分。
* @param ctx 上下文
* @param slot 背包字
* @param provider 配表
*/
private _placePriority(ctx: AiPlannerContext, slot: { weapon2Id: number; level?: number }, provider: WeaponComposeConfigProvider): number {
const w2Type = Number(provider.getWeapon2Type(slot.weapon2Id)) as Weapon2TileType;
const heroAfter = this._probe.predictHeroCountAfterPlace(ctx.bagSession, slot.weapon2Id);
const heroBase = this._probe.predictHeroCountAfterPlace(ctx.bagSession, -1);
const heroGain = heroAfter - heroBase;
let score = heroGain * 100;
const level = Math.max(1, Math.floor(slot.level ?? 1));
if (isElementGlyphType(w2Type)) {
score += 600 + level * 10;
}
score += level;
const w1 = this._probe.resolveWeapon1Id(slot) ?? 0;
const rangeCells = Math.max(1, Math.ceil(this._probe.rangePxForWeapon1(w1) / BattleMapSpec.CELL_SIZE));
score += Math.max(0, 12 - rangeCells) * 20;
return score;
}
}