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

2099 lines
89 KiB

import {
MapCellKind,
Weapon2TileType,
type BagSlotRecord,
type ComposedUnit,
type DragSource,
type DropTarget,
type MapCellSnapshot,
type MapTileRecord,
} from 'db://assets/mx/module/merge';
import type { BattleBagSession } from '../../bag/BattleBagSession';
import { WeaponComposeConfigProvider } from '../../bag/WeaponComposeConfigProvider';
import type { MapManager } from '../../map/legacy/MapManager';
import type { MapGrid } from '../../map/legacy/MapGrid';
import { MapDragGuideHelper } from '../../view/MapDragGuideHelper';
import { TableNames } from '../../../game/ConfigTableData';
import { heroNormalSkillId, isComboHero } from '../../config/BattleHeroTable';
import { resolveTowerRangePx } from '../../combat/TowerStatResolver';
import { BattleMapSpec, mapGridToLogicCell } from '../../map/BattleMapSpec';
import { AI_PLACE_CAMP } from '../runtime/AiCampArea';
import { isNonCombatWeapon1 } from '../../heros/HeroActorRes';
type TileLike = { weapon2Id: number; weapon1Id?: number; level?: number };
interface BoardSnapshot {
prepActive: boolean;
bagSlots: Array<BagSlotRecord | null>;
mapCells: Array<{ gridIndex: number; cell: MapCellSnapshot }>;
mapTiles: Array<{ gridIndex: number; tile: MapTileRecord }>;
compositions: ComposedUnit[];
}
/** 未成多格武将的自由组合字(地图或背包) */
interface FreeComboPiece {
/** Text.id */
weapon2Id: number;
/** 拖拽源 */
source: DragSource;
/** 地图格索引;背包为 -1 */
mapGridIndex: number;
}
/** 拼装成将规划结果 */
interface AssembleHeroPlan {
/** 逐步落子(左→右) */
moves: Array<{ source: DragSource; target: DropTarget }>;
/** 阵型分(覆盖 + 路径出口 + 短前长后) */
formation: number;
/** 左锚 gridIndex(平局取小) */
anchorIndex: number;
/** 武将射程像素(短射程优先拼装) */
rangePx: number;
}
/**
* AI 盘面只读探测(升级/合成/空位/铲子),执行仍走 dropLogical。
*/
export class AiBoardProbe {
/**
* 解析字块对应 Weapon1 id。
* @param tile 背包或地图字
*/
resolveWeapon1Id(tile: TileLike): number | null {
WeaponComposeConfigProvider.instance.load();
const provider = WeaponComposeConfigProvider.instance;
if (tile.weapon1Id != null && tile.weapon1Id > 0) return tile.weapon1Id;
return provider.getWeapon1ByGroupSequence([tile.weapon2Id]);
}
/**
* 组合体当前最高局内等级。
* @param session 背包会话
* @param compose 合成体
*/
composeMaxLevel(session: BattleBagSession, compose: ComposedUnit): number {
let max = 1;
for (let i = 0; i < compose.gridIndices.length; i++) {
const lv = session.merge.map.getTile(compose.gridIndices[i])?.level ?? 1;
if (lv > max) max = lv;
}
return max;
}
/**
* 源落到目标格是否会升级(对齐 MapDragGuideHelper 规则)。
* @param session 会话
* @param gridIndex 目标格
* @param source 源字
* @param fromMapGrid 若源来自地图则填源格
*/
canUpgradeDropOnGrid(session: BattleBagSession, gridIndex: number, source: TileLike, fromMapGrid?: number): boolean {
WeaponComposeConfigProvider.instance.load();
const provider = WeaponComposeConfigProvider.instance;
if (provider.passiveSpawnKeepsDigCell(source.weapon2Id)) {
return this._canKeepDigUpgradeOnGrid(session, gridIndex, source, fromMapGrid);
}
const w2Type = Number(provider.getWeapon2Type(source.weapon2Id)) as Weapon2TileType;
if (w2Type !== Weapon2TileType.Single && w2Type !== Weapon2TileType.Combo) return false;
if (!session.merge.map.hasTile(gridIndex)) return false;
if (!session.validator.isAllowedCampForPlace(gridIndex)) return false;
if (fromMapGrid != null && fromMapGrid === gridIndex) return false;
const level = source.level ?? 1;
const compose = this._findComposeAt(session, gridIndex);
if (compose) {
if (fromMapGrid != null && compose.gridIndices.indexOf(fromMapGrid) >= 0) return false;
const composeLevel = this.composeMaxLevel(session, compose);
if (composeLevel >= provider.getMaxWeaponLevel(compose.weapon1Id)) return false;
if (provider.isSingleCellWeapon1(compose.weapon1Id)) {
if (w2Type !== Weapon2TileType.Single) return false;
const srcW1 = this.resolveWeapon1Id(source);
if (srcW1 == null || srcW1 !== compose.weapon1Id) return false;
return level === composeLevel;
}
if (w2Type !== Weapon2TileType.Combo) return false;
return provider.getWeapon1Group(compose.weapon1Id).indexOf(source.weapon2Id) >= 0;
}
if (w2Type !== Weapon2TileType.Single) return false;
const tile = session.merge.map.getTile(gridIndex);
if (!tile) return false;
const srcW1 = this.resolveWeapon1Id(source);
const dstW1 = this.resolveWeapon1Id(tile);
if (srcW1 == null || dstW1 == null || srcW1 !== dstW1) return false;
if (!provider.isSingleCellWeapon1(srcW1)) return false;
if (Number(provider.getWeapon2Type(tile.weapon2Id)) !== Weapon2TileType.Single) return false;
if (level !== (tile.level ?? 1)) return false;
return level < provider.getMaxWeaponLevel(srcW1);
}
/**
* 两槽是否可在背包内合成升级(对齐 UpgradeSystem.canMergeInBag)。
* @param source 源槽字
* @param target 目标槽字
*/
canUpgradeInBag(source: TileLike, target: TileLike): boolean {
WeaponComposeConfigProvider.instance.load();
const provider = WeaponComposeConfigProvider.instance;
if (provider.passiveSpawnKeepsDigCell(source.weapon2Id)
&& provider.passiveSpawnKeepsDigCell(target.weapon2Id)) {
if (source.weapon2Id !== target.weapon2Id) return false;
const lv = source.level ?? 1;
if (lv !== (target.level ?? 1)) return false;
return lv < provider.getKeepDigTileMaxLevel(source.weapon2Id);
}
if (Number(provider.getWeapon2Type(source.weapon2Id)) !== Weapon2TileType.Single) return false;
if (Number(provider.getWeapon2Type(target.weapon2Id)) !== Weapon2TileType.Single) return false;
const srcW1 = this.resolveWeapon1Id(source);
const dstW1 = this.resolveWeapon1Id(target);
if (srcW1 == null || dstW1 == null || srcW1 !== dstW1) return false;
if (!provider.isSingleCellWeapon1(srcW1)) return false;
const srcLv = source.level ?? 1;
const dstLv = target.level ?? 1;
if (srcLv !== dstLv) return false;
return srcLv < provider.getMaxWeaponLevel(srcW1);
}
/**
* 蟠桃树落到目标格是否会合成升级。
* @param session 会话
* @param gridIndex 目标格
* @param source 源字
* @param fromMapGrid 若源来自地图则填源格
*/
private _canKeepDigUpgradeOnGrid(session: BattleBagSession, gridIndex: number, source: TileLike, fromMapGrid?: number): boolean {
if (fromMapGrid != null && fromMapGrid === gridIndex) return false;
if (!session.validator.isAllowedCampForPlace(gridIndex)) return false;
const target = session.merge.map.getTile(gridIndex);
if (!target || target.weapon2Id !== source.weapon2Id) return false;
const provider = WeaponComposeConfigProvider.instance;
if (!provider.passiveSpawnKeepsDigCell(target.weapon2Id)) return false;
const lv = source.level ?? 1;
if (lv !== (target.level ?? 1)) return false;
return lv < provider.getKeepDigTileMaxLevel(source.weapon2Id);
}
/**
* 空 Dig 或空 Put 是否可落蟠桃树(优先与被动选格一致)。
* @param session 会话
* @param mg 地图格
*/
canDropKeepDigOnEmptyGrid(session: BattleBagSession, mg: MapGrid): boolean {
if (!mg?.node?.isValid || !mg.node.active) return false;
if (!session.validator.isOwnerCampCell(mg.Index)) return false;
if (session.merge.map.hasTile(mg.Index)) return false;
if (MapDragGuideHelper.hasSpiderWeb(mg)) return false;
const kind = session.merge.map.getCellKind(mg.Index);
return kind === MapCellKind.Dig || kind === MapCellKind.Put;
}
/**
* 查找一步升级:背包内 → 背包→地图 → 地图→地图。
* @param session 会话
* @param map 地图
*/
findUpgradeAction(session: BattleBagSession, map: MapManager): { source: DragSource; target: DropTarget } | null {
const slots = session.getBagSlots();
for (let a = 0; a < slots.length; a++) {
const src = slots[a];
if (!src) continue;
for (let b = 0; b < slots.length; b++) {
if (a === b) continue;
const dst = slots[b];
if (!dst) continue;
if (!this.canUpgradeInBag(src, dst)) continue;
return { source: { kind: 'bag', slotIndex: a }, target: { kind: 'bag', slotIndex: b } };
}
}
const grids = this._listActiveGrids(map);
for (let s = 0; s < slots.length; s++) {
const slot = slots[s];
if (!slot) continue;
for (let g = 0; g < grids.length; g++) {
const mg = grids[g];
if (!this.canUpgradeDropOnGrid(session, mg.Index, slot)) continue;
return { source: { kind: 'bag', slotIndex: s }, target: { kind: 'map', gridIndex: mg.Index } };
}
}
for (let i = 0; i < grids.length; i++) {
const from = grids[i];
const tile = session.merge.map.getTile(from.Index);
if (!tile) continue;
for (let j = 0; j < grids.length; j++) {
const to = grids[j];
if (from.Index === to.Index) continue;
if (!this.canUpgradeDropOnGrid(session, to.Index, tile, from.Index)) continue;
return {
source: { kind: 'map', gridIndex: from.Index },
target: { kind: 'map', gridIndex: to.Index },
};
}
}
return null;
}
/**
* 查找一步「合成」:落空位或纠正邻接后多格武将数增加(同分取阵型分更高)。
* 含背包→空、地图→空、地图↔地图(逆序互换成正序)。散字多步拼装见 findAssembleHeroAction。
* @param session 会话
* @param map 地图
*/
findMergeFormAction(session: BattleBagSession, map: MapManager): { source: DragSource; target: DropTarget } | null {
const beforeHeroes = this._multiHeroCount(session);
let best: { source: DragSource; target: DropTarget; form: number; gridIndex: number } | null = null;
const emptyPut = this.countEmptyTextPut(session, map);
const digLeft = this.listEmptyDigGrids(session, map).length;
const slots = session.getBagSlots();
const grids = this._listActiveGrids(map);
for (let s = 0; s < slots.length; s++) {
const slot = slots[s];
if (!slot) continue;
const w2Type = Number(WeaponComposeConfigProvider.instance.getWeapon2Type(slot.weapon2Id)) as Weapon2TileType;
if (w2Type !== Weapon2TileType.Single && w2Type !== Weapon2TileType.Combo) continue;
const w1Raw = this.resolveWeapon1Id(slot) ?? 0;
const w1 = w1Raw > 0 ? w1Raw : (this.comboHeroIdForText(slot.weapon2Id) ?? 0);
for (let g = 0; g < grids.length; g++) {
const mg = grids[g];
if (!session.validator.isAllowedCampForPlace(mg.Index)) continue;
if (!this.canDropTextOnEmptyGrid(session, mg, w2Type)) continue;
const source: DragSource = { kind: 'bag', slotIndex: s };
const target: DropTarget = { kind: 'map', gridIndex: mg.Index };
if (!this._dryRunIncreasesHeroes(session, source, target, beforeHeroes)) continue;
const form = this.formationScore(map, mg, w1);
best = this._preferMergeCandidate(best, source, target, form, mg.Index);
}
}
for (let i = 0; i < grids.length; i++) {
const from = grids[i];
if (!session.validator.isAllowedCampForPlace(from.Index)) continue;
const tile = session.merge.map.getTile(from.Index);
if (!tile) continue;
const w2Type = Number(WeaponComposeConfigProvider.instance.getWeapon2Type(tile.weapon2Id)) as Weapon2TileType;
if (w2Type !== Weapon2TileType.Single && w2Type !== Weapon2TileType.Combo) continue;
if (this._findComposeAt(session, from.Index)) continue;
const w1Raw = this.resolveWeapon1Id(tile) ?? 0;
const w1 = w1Raw > 0 ? w1Raw : (this.comboHeroIdForText(tile.weapon2Id) ?? 0);
for (let j = 0; j < grids.length; j++) {
const to = grids[j];
if (from.Index === to.Index) continue;
if (!session.validator.isAllowedCampForPlace(to.Index)) continue;
const toTile = session.merge.map.getTile(to.Index);
if (!toTile) {
if (!this.canDropTextOnEmptyGrid(session, to, w2Type)) continue;
} else {
if (this._findComposeAt(session, to.Index)) continue;
const toType = Number(WeaponComposeConfigProvider.instance.getWeapon2Type(toTile.weapon2Id)) as Weapon2TileType;
if (toType !== Weapon2TileType.Single && toType !== Weapon2TileType.Combo) continue;
// 有可放空位或仍可铲时,禁止踩普通战斗字成将(避免棍/枪被换进背包再 sell)
if (toType === Weapon2TileType.Single && (emptyPut > 0 || digLeft > 0)) continue;
}
const source: DragSource = { kind: 'map', gridIndex: from.Index };
const target: DropTarget = { kind: 'map', gridIndex: to.Index };
if (!this._dryRunIncreasesHeroes(session, source, target, beforeHeroes)) continue;
const form = this.formationScore(map, to, w1);
best = this._preferMergeCandidate(best, source, target, form, to.Index);
}
}
return best ? { source: best.source, target: best.target } : null;
}
/**
* 全局拼装可成将英雄字:背包+地图散字凑齐 Hero.text 后,挪到横向跨度逐步合成。
* 可清开可移动挡格;优先路径出口阵型,其次短射程,再次步数少。
* @param session 会话
* @param map 地图
*/
findAssembleHeroAction(session: BattleBagSession, map: MapManager): { moves: Array<{ source: DragSource; target: DropTarget }> } | null {
const pieces = this._listFreeComboPieces(session, map);
if (pieces.length < 2) return null;
const heroes = gg.data.table.getTableList<ITableHero>(TableNames.Hero) ?? [];
let best: AssembleHeroPlan | null = null;
for (let h = 0; h < heroes.length; h++) {
const hero = heroes[h];
if (!hero || !isComboHero(hero)) continue;
const group = hero.text;
if (!Array.isArray(group) || group.length < 2) continue;
if (!this._hasAllGroupPieces(pieces, group)) continue;
const weapon1Id = Number(hero.id);
if (this._hasMultiHeroWeapon1(session, weapon1Id)) continue;
const planned = this._planAssembleHero(session, map, group, pieces, weapon1Id);
if (!planned) continue;
if (!best
|| planned.formation > best.formation
|| (planned.formation === best.formation && planned.rangePx < best.rangePx)
|| (planned.formation === best.formation && planned.rangePx === best.rangePx && planned.moves.length < best.moves.length)
|| (planned.formation === best.formation && planned.rangePx === best.rangePx && planned.moves.length === best.moves.length && planned.anchorIndex < best.anchorIndex)) {
best = planned;
}
}
return best ? { moves: best.moves } : null;
}
/**
* 无空位时:同 id 自由散字多于 1,多余的收回背包空槽(腾出落点);有空位则保留不收。
* @param session 会话
* @param map 地图
*/
findDuplicateComboRetractAction(session: BattleBagSession, map: MapManager): { source: DragSource; target: DropTarget } | null {
if (this.countEmptyTextPut(session, map) > 0) return null;
if (this.listEmptyDigGrids(session, map).length > 0) return null;
const emptySlot = this._findEmptyBagSlot(session);
if (emptySlot < 0) return null;
const pieces = this._listFreeComboPiecesOnMap(session, map);
if (pieces.length < 2) return null;
const ids: number[] = [];
const counts: number[] = [];
for (let i = 0; i < pieces.length; i++) {
const id = pieces[i].weapon2Id;
let hit = -1;
for (let k = 0; k < ids.length; k++) {
if (ids[k] === id) {
hit = k;
break;
}
}
if (hit < 0) {
ids.push(id);
counts.push(1);
} else {
counts[hit]++;
}
}
for (let k = 0; k < ids.length; k++) {
if (counts[k] <= 1) continue;
const id = ids[k];
let keepIndex = -1;
let keepForm = -1e99;
const extras: number[] = [];
for (let i = 0; i < pieces.length; i++) {
if (pieces[i].weapon2Id !== id) continue;
const mg = map.getMapGridByIndex(pieces[i].mapGridIndex);
if (!mg?.node?.isValid) continue;
const gridIndex = pieces[i].mapGridIndex;
const w1 = this.comboHeroIdForText(id) ?? 0;
const form = this.formationScore(map, mg, w1);
if (keepIndex < 0 || form > keepForm || (form === keepForm && gridIndex < keepIndex)) {
if (keepIndex >= 0) extras.push(keepIndex);
keepIndex = gridIndex;
keepForm = form;
} else {
extras.push(gridIndex);
}
}
if (keepIndex < 0 || extras.length <= 0) continue;
let retractGrid = extras[0];
for (let e = 1; e < extras.length; e++) {
if (extras[e] < retractGrid) retractGrid = extras[e];
}
return {
source: { kind: 'map', gridIndex: retractGrid },
target: { kind: 'bag', slotIndex: emptySlot },
};
}
return null;
}
/**
* 无空位时:背包字优先换到「重复自由散字」格上(同 id 多于 1 的那份),腾出唯一散字。
* 背包与目标同 id 则跳过(无意义互换)。
* @param session 会话
* @param map 地图
*/
findDuplicateComboReplaceAction(session: BattleBagSession, map: MapManager): { source: DragSource; target: DropTarget } | null {
if (this.countEmptyTextPut(session, map) > 0) return null;
if (this.listEmptyDigGrids(session, map).length > 0) return null;
WeaponComposeConfigProvider.instance.load();
const pieces = this._listFreeComboPiecesOnMap(session, map);
if (pieces.length < 2) return null;
const ids: number[] = [];
const counts: number[] = [];
for (let i = 0; i < pieces.length; i++) {
const id = pieces[i].weapon2Id;
let hit = -1;
for (let k = 0; k < ids.length; k++) {
if (ids[k] === id) {
hit = k;
break;
}
}
if (hit < 0) {
ids.push(id);
counts.push(1);
} else {
counts[hit]++;
}
}
const victimGrids: number[] = [];
for (let k = 0; k < ids.length; k++) {
if (counts[k] <= 1) continue;
const id = ids[k];
let keepIndex = -1;
let keepForm = -1e99;
for (let i = 0; i < pieces.length; i++) {
if (pieces[i].weapon2Id !== id) continue;
const mg = map.getMapGridByIndex(pieces[i].mapGridIndex);
if (!mg?.node?.isValid) continue;
const form = this.formationScore(map, mg, this.comboHeroIdForText(id) ?? 0);
if (keepIndex < 0 || form > keepForm || (form === keepForm && pieces[i].mapGridIndex < keepIndex)) {
keepIndex = pieces[i].mapGridIndex;
keepForm = form;
}
}
for (let i = 0; i < pieces.length; i++) {
if (pieces[i].weapon2Id !== id) continue;
if (pieces[i].mapGridIndex === keepIndex) continue;
victimGrids.push(pieces[i].mapGridIndex);
}
}
if (victimGrids.length <= 0) return null;
const slots = session.getBagSlots();
let best: { slotIndex: number; gridIndex: number; preferSame: number; form: number } | null = null;
for (let s = 0; s < slots.length; s++) {
const slot = slots[s];
if (!slot) continue;
const srcType = Number(WeaponComposeConfigProvider.instance.getWeapon2Type(slot.weapon2Id)) as Weapon2TileType;
if (srcType !== Weapon2TileType.Single && srcType !== Weapon2TileType.Combo) continue;
for (let v = 0; v < victimGrids.length; v++) {
const gridIndex = victimGrids[v];
const tile = session.merge.map.getTile(gridIndex);
if (!tile) continue;
if (tile.weapon2Id === slot.weapon2Id) continue;
const mg = map.getMapGridByIndex(gridIndex);
if (!mg?.node?.isValid) continue;
const w1 = this.resolveWeapon1Id(slot) ?? (this.comboHeroIdForText(slot.weapon2Id) ?? 0);
const form = this.formationScore(map, mg, w1);
const preferSame = this._sameHeroGroup(slot.weapon2Id, tile.weapon2Id) ? 1 : 0;
if (!best
|| preferSame > best.preferSame
|| (preferSame === best.preferSame && form > best.form)
|| (preferSame === best.preferSame && form === best.form && s < best.slotIndex)
|| (preferSame === best.preferSame && form === best.form && s === best.slotIndex && gridIndex < best.gridIndex)) {
best = { slotIndex: s, gridIndex, preferSame, form };
}
}
}
if (!best) return null;
return {
source: { kind: 'bag', slotIndex: best.slotIndex },
target: { kind: 'map', gridIndex: best.gridIndex },
};
}
/**
* 两字是否同属某一合成武将的 Hero.text。
* @param a Text.id
* @param b Text.id
*/
private _sameHeroGroup(a: number, b: number): boolean {
const ha = this.comboHeroIdForText(a);
const hb = this.comboHeroIdForText(b);
return ha != null && hb != null && ha === hb;
}
/**
* 场上未成将的同 id 自由散字个数。
* @param session 会话
* @param map 地图
* @param weapon2Id Text.id
*/
countFreeComboOnMap(session: BattleBagSession, map: MapManager, weapon2Id: number): number {
const need = Number(weapon2Id);
let n = 0;
const pieces = this._listFreeComboPiecesOnMap(session, map);
for (let i = 0; i < pieces.length; i++) {
if (pieces[i].weapon2Id === need) n++;
}
return n;
}
/**
* 合成候选字典序:阵型分高优先,平局取更小 gridIndex。
* @param best 当前最优
* @param source 源
* @param target 目标
* @param form 阵型分
* @param gridIndex 目标格
*/
private _preferMergeCandidate(
best: { source: DragSource; target: DropTarget; form: number; gridIndex: number } | null,
source: DragSource,
target: DropTarget,
form: number,
gridIndex: number,
): { source: DragSource; target: DropTarget; form: number; gridIndex: number } {
if (!best || form > best.form || (form === best.form && gridIndex < best.gridIndex)) {
return { source, target, form, gridIndex };
}
return best;
}
/**
* 空 Put 格是否可落普通/组合字(逻辑格为准,对齐拖拽 valid)。
* @param session 会话
* @param mg 地图格
* @param w2Type 字类型
*/
canDropTextOnEmptyGrid(session: BattleBagSession, mg: MapGrid, w2Type: Weapon2TileType): boolean {
if (w2Type !== Weapon2TileType.Single && w2Type !== Weapon2TileType.Combo) return false;
if (!mg?.node?.isValid || !mg.node.active) return false;
if (!session.validator.isAllowedCampForPlace(mg.Index)) return false;
if (session.merge.map.getCellKind(mg.Index) !== MapCellKind.Put) return false;
if (session.merge.map.hasTile(mg.Index)) return false;
if (MapDragGuideHelper.hasSpiderWeb(mg)) return false;
return true;
}
/**
* AI 阵营空字格数量(以 merge 逻辑 Put 为准)。
* @param session 会话
* @param map 地图
*/
countEmptyTextPut(session: BattleBagSession, map: MapManager): number {
let n = 0;
const grids = this._listActiveGrids(map);
for (let i = 0; i < grids.length; i++) {
const mg = grids[i];
if (!session.validator.isAllowedCampForPlace(mg.Index)) continue;
if (session.merge.map.getCellKind(mg.Index) !== MapCellKind.Put) continue;
if (session.merge.map.hasTile(mg.Index)) continue;
// 与 canDropTextOnEmptyGrid 一致:蛛网格不可落,不计入空位
if (MapDragGuideHelper.hasSpiderWeb(mg)) continue;
n++;
}
return n;
}
/**
* 列出 AI 阵营可挖空格(以 merge 逻辑 Dig 为准)。
* @param session 会话
* @param map 地图
*/
listEmptyDigGrids(session: BattleBagSession, map: MapManager): MapGrid[] {
const out: MapGrid[] = [];
const grids = this._listActiveGrids(map);
for (let i = 0; i < grids.length; i++) {
const mg = grids[i];
if (!session.validator.isAllowedCampForPlace(mg.Index)) continue;
if (session.merge.map.getCellKind(mg.Index) !== MapCellKind.Dig) continue;
// 可放置优先:编辑 dig+putText 双标不当挖格
if (mg.isCanPutText) continue;
// 双标 walk+dig 的路径格以 Walkable 为准,禁止当挖格铲穿
if (mg.isCanWalk) continue;
if (session.merge.map.hasTile(mg.Index)) continue;
out.push(mg);
}
return out;
}
/**
* 背包中第一个铲子槽。
* @param session 会话
*/
findShovelSlotIndex(session: BattleBagSession): number {
WeaponComposeConfigProvider.instance.load();
const slots = session.getBagSlots();
for (let i = 0; i < slots.length; i++) {
const slot = slots[i];
if (!slot) continue;
if (Number(WeaponComposeConfigProvider.instance.getWeapon2Type(slot.weapon2Id)) === Weapon2TileType.Shovel) {
return i;
}
}
return -1;
}
/**
* 单位索敌射程像素(与 coverage / 实战 resolveTowerRangePx 一致)。
* @param weapon1Id Hero id;<=0 时默认 3 格
*/
rangePxForWeapon1(weapon1Id: number): number {
if (weapon1Id > 0) {
const hero = gg.data.table.getTableData<ITableHero>(TableNames.Hero, weapon1Id);
if (hero) {
const skillId = heroNormalSkillId(hero, 1);
const skill = skillId > 0
? gg.data.table.getTableData<ITableSkill>(TableNames.Skill, skillId)
: null;
return resolveTowerRangePx(hero, skill);
}
}
return 3 * BattleMapSpec.CELL_SIZE;
}
/**
* §7 覆盖分:射程内己方路径格数。
* @param map 地图
* @param anchor 落点格
* @param weapon1Id Hero id;<=0 时用默认 3 格
*/
coverageScore(map: MapManager, anchor: MapGrid, weapon1Id: number): number {
const rangePx = this.rangePxForWeapon1(weapon1Id);
const ax = anchor.node.worldPosition.x;
const ay = anchor.node.worldPosition.y;
const range2 = rangePx * rangePx;
let score = 0;
const grids = this._listActiveGrids(map);
for (let i = 0; i < grids.length; i++) {
const mg = grids[i];
if ((mg.CampAreaID | 0) !== AI_PLACE_CAMP) continue;
if (!mg.isCanWalk) continue;
const dx = mg.node.worldPosition.x - ax;
const dy = mg.node.worldPosition.y - ay;
if (dx * dx + dy * dy <= range2) score++;
}
return score;
}
/**
* 阵型分:能战斗须能打到路;**路径出口优先**(火力靠近出口/攻击点),再比覆盖,再短前长后。
* 营等无攻击:靠里且靠入口侧,不占出口火力位。
* @param map 地图
* @param anchor 落点
* @param weapon1Id Hero id
*/
formationScore(map: MapManager, anchor: MapGrid, weapon1Id: number): number {
const pathDistCells = this.pathDistToWalkPx(map, anchor) / BattleMapSpec.CELL_SIZE;
if (isNonCombatWeapon1(weapon1Id)) {
// 靠里 + 靠入口侧(出口分低),把出口火力位让给战斗单位
return pathDistCells * 10000 - this.walkPathFrontNear(map, anchor) * 100;
}
const cover = this.coverageScore(map, anchor, weapon1Id);
if (cover <= 0) return -1e9;
const rangeCells = Math.max(0.5, this.rangePxForWeapon1(weapon1Id) / BattleMapSpec.CELL_SIZE);
const frontBias = 2.5 - rangeCells;
const pathFront = this.pathFrontScore(map, anchor, weapon1Id);
// 路径出口为主;覆盖与短前长后为次
let score = pathFront * 100000 + cover * 1000 + frontBias * (-pathDistCells) * 100;
// 多格合成神将额外贴路:优先占路边火力位,高于 1–3 级单字抢位
const hero = gg.data.table.getTableData<ITableHero>(TableNames.Hero, weapon1Id);
if (hero && isComboHero(hero)) {
const hug = Math.max(0, 2.5 - pathDistCells);
score += hug * 25000;
}
return score;
}
/**
* 路径出口分:射程内己方路径格越靠近出口/攻击点越高。
* @param map 地图
* @param anchor 落点
* @param weapon1Id Hero id
*/
pathFrontScore(map: MapManager, anchor: MapGrid, weapon1Id: number): number {
const rangePx = this.rangePxForWeapon1(weapon1Id);
const ax = anchor.node.worldPosition.x;
const ay = anchor.node.worldPosition.y;
const range2 = rangePx * rangePx;
let best = -1e9;
let hit = false;
const grids = this._listActiveGrids(map);
for (let i = 0; i < grids.length; i++) {
const mg = grids[i];
if ((mg.CampAreaID | 0) !== AI_PLACE_CAMP) continue;
if (!mg.isCanWalk) continue;
const dx = mg.node.worldPosition.x - ax;
const dy = mg.node.worldPosition.y - ay;
if (dx * dx + dy * dy > range2) continue;
hit = true;
const front = this._pathFrontOfWalk(map, mg);
if (front > best) best = front;
}
return hit ? best : 0;
}
/**
* 最近己方路径格的出口分(营选位:越低越靠入口侧)。
* @param map 地图
* @param anchor 落点
*/
walkPathFrontNear(map: MapManager, anchor: MapGrid): number {
const ax = anchor.node.worldPosition.x;
const ay = anchor.node.worldPosition.y;
let minD2 = Number.POSITIVE_INFINITY;
let nearest: MapGrid | null = null;
const grids = this._listActiveGrids(map);
for (let i = 0; i < grids.length; i++) {
const mg = grids[i];
if ((mg.CampAreaID | 0) !== AI_PLACE_CAMP) continue;
if (!mg.isCanWalk) continue;
const dx = mg.node.worldPosition.x - ax;
const dy = mg.node.worldPosition.y - ay;
const d2 = dx * dx + dy * dy;
if (d2 < minD2) {
minD2 = d2;
nearest = mg;
}
}
if (!nearest) return 0;
return this._pathFrontOfWalk(map, nearest);
}
/**
* 单格路径出口度量:距攻击点代价取负(越近出口越高);无寻路则用 +距出怪口。
* @param map 地图
* @param walk 己方可走格
*/
private _pathFrontOfWalk(map: MapManager, walk: MapGrid): number {
const session = gg.game?.CurentBattle?.session ?? null;
if (session?.pathfinder && session.grid) {
const cell = mapGridToLogicCell(walk.Row, walk.Col, session.grid.rows, session.grid.cols);
if (cell) {
const cost = session.pathfinder.getPathCost(cell, AI_PLACE_CAMP);
if (Number.isFinite(cost)) return -cost;
}
}
return this._spawnDistCells(map, walk);
}
/**
* 落点到己方路径格的最短距离(像素)。
* @param map 地图
* @param anchor 落点
*/
pathDistToWalkPx(map: MapManager, anchor: MapGrid): number {
const ax = anchor.node.worldPosition.x;
const ay = anchor.node.worldPosition.y;
let minD2 = Number.POSITIVE_INFINITY;
const grids = this._listActiveGrids(map);
for (let i = 0; i < grids.length; i++) {
const mg = grids[i];
if ((mg.CampAreaID | 0) !== AI_PLACE_CAMP) continue;
if (!mg.isCanWalk) continue;
const dx = mg.node.worldPosition.x - ax;
const dy = mg.node.worldPosition.y - ay;
const d2 = dx * dx + dy * dy;
if (d2 < minD2) minD2 = d2;
}
if (!(minD2 < Number.POSITIVE_INFINITY)) return 1e9;
return Math.sqrt(minD2);
}
/**
* 格到己方出怪口的最短距离(格数)。
* @param map 地图
* @param anchor 参考格
*/
private _spawnDistCells(map: MapManager, anchor: MapGrid): number {
const ax = anchor.node.worldPosition.x;
const ay = anchor.node.worldPosition.y;
let minD2 = Number.POSITIVE_INFINITY;
const parent = map.spawnPointParent;
const children = parent?.children;
if (children) {
for (let i = 0; i < children.length; i++) {
const n = children[i];
if (!n?.isValid) continue;
const name = n.name ?? '';
if (name.indexOf('spawnPoint_') !== 0) continue;
const port = Number(name.substring('spawnPoint_'.length));
if (!Number.isInteger(port) || port < 1) continue;
if ((map.spawnPortCampArea.get(port) | 0) !== AI_PLACE_CAMP) continue;
const dx = n.worldPosition.x - ax;
const dy = n.worldPosition.y - ay;
const d2 = dx * dx + dy * dy;
if (d2 < minD2) minD2 = d2;
}
}
if (!(minD2 < Number.POSITIVE_INFINITY)) return 1e6;
return Math.sqrt(minD2) / BattleMapSpec.CELL_SIZE;
}
/**
* 组合字所属多格武将 id(Hero.text 含该 Text.id);找不到返回 null。
* @param weapon2Id Text.id
*/
comboHeroIdForText(weapon2Id: number): number | null {
const id = Number(weapon2Id);
const heroes = gg.data.table.getTableList<ITableHero>(TableNames.Hero) ?? [];
for (let h = 0; h < heroes.length; h++) {
const hero = heroes[h];
if (!hero || !isComboHero(hero)) continue;
const group = hero.text;
if (!Array.isArray(group)) continue;
for (let i = 0; i < group.length; i++) {
if (Number(group[i]) === id) return Number(hero.id);
}
}
return null;
}
/**
* 多格武将阵型重摆:优先空跨度;否则清走/互换挡路的单格战斗单位后贴出口。
* @param session 会话
* @param map 地图
*/
findHeroRepositionAction(session: BattleBagSession, map: MapManager): { moves: Array<{ source: DragSource; target: DropTarget }> } | null {
const multi = this.findMultiCellHeroRepositionAction(session, map);
if (multi) return multi;
return this._findSingleFormationReposition(session, map);
}
/**
* 仅多格合成神将贴路/贴出口重摆(不含单格阵型优化)。
* @param session 会话
* @param map 地图
*/
findMultiCellHeroRepositionAction(session: BattleBagSession, map: MapManager): { moves: Array<{ source: DragSource; target: DropTarget }> } | null {
const comps = session.compositions;
let bestGain = 0;
let bestMoves: Array<{ source: DragSource; target: DropTarget }> | null = null;
for (let c = 0; c < comps.length; c++) {
const comp = comps[c];
if (!comp || comp.gridIndices.length < 2) continue;
const hero = gg.data.table.getTableData<ITableHero>(TableNames.Hero, comp.weapon1Id);
if (!hero || !isComboHero(hero)) continue;
const sorted = this._sortGridIndicesByCol(session, map, comp.gridIndices);
if (!sorted || sorted.length < 2) continue;
const leftMg = map.getMapGridByIndex(sorted[0]);
if (!leftMg?.node?.isValid) continue;
const curScore = this.formationScore(map, leftMg, comp.weapon1Id);
const len = sorted.length;
const grids = this._listActiveGrids(map);
for (let g = 0; g < grids.length; g++) {
const anchor = grids[g];
if (!session.validator.isAllowedCampForPlace(anchor.Index)) continue;
const emptySpan = this._buildEmptyHeroSpan(session, map, anchor, len, sorted);
if (emptySpan) {
const score = this.formationScore(map, emptySpan[0], comp.weapon1Id);
const gain = score - curScore;
if (gain > bestGain) {
const moves = this._buildNonOverlapMoves(sorted, emptySpan);
if (moves && moves.length > 0) {
bestGain = gain;
bestMoves = moves;
}
}
continue;
}
const displaced = this._planHeroSpanDisplace(session, map, anchor, len, sorted, comp.weapon1Id, curScore);
if (!displaced || displaced.gain <= bestGain) continue;
bestGain = displaced.gain;
bestMoves = displaced.moves;
}
}
if (bestMoves && bestGain > 0) return { moves: bestMoves };
return null;
}
/**
* 场上是否存在多格合成神将。
* @param session 会话
*/
hasMultiCellHero(session: BattleBagSession): boolean {
const comps = session.compositions;
for (let i = 0; i < comps.length; i++) {
const comp = comps[i];
if (comp && comp.gridIndices.length > 1) return true;
}
return false;
}
/**
* 目标横跨被单格单位占用:清到可攻击空位或与神将整段互换,使神将贴出口。
* @param session 会话
* @param map 地图
* @param anchor 左锚
* @param len 字数
* @param occupied 当前神将格
* @param weapon1Id 神将 id
* @param curScore 当前阵型分
*/
private _planHeroSpanDisplace(
session: BattleBagSession,
map: MapManager,
anchor: MapGrid,
len: number,
occupied: readonly number[],
weapon1Id: number,
curScore: number,
): { moves: Array<{ source: DragSource; target: DropTarget }>; gain: number } | null {
const built = this._buildDisplaceableHeroSpan(session, map, anchor, len, occupied);
if (!built) return null;
const score = this.formationScore(map, built.span[0], weapon1Id);
const gain = score - curScore;
if (gain <= 0) return null;
const parkMoves = this._buildHeroBlockerParkMoves(session, map, built.span, built.blockers, occupied);
if (parkMoves) {
const heroMoves = this._buildNonOverlapMoves(occupied, built.span);
if (!heroMoves || heroMoves.length <= 0) return null;
const moves = parkMoves.concat(heroMoves);
return { moves, gain };
}
const swapMoves = this._buildHeroSpanPairSwapMoves(occupied, built.span, built.blockers);
if (!swapMoves) return null;
return { moves: swapMoves, gain };
}
/**
* 横跨 Put:可空,或仅被可挤的单格战斗/无攻击字占用(不可拆其它多格神将)。
* @param session 会话
* @param map 地图
* @param anchor 左锚
* @param len 字数
* @param occupied 当前神将格(禁止重叠)
*/
private _buildDisplaceableHeroSpan(
session: BattleBagSession,
map: MapManager,
anchor: MapGrid,
len: number,
occupied: readonly number[],
): { span: MapGrid[]; blockers: Array<{ gridIndex: number; weapon1Id: number }> } | null {
const span: MapGrid[] = [];
const blockers: Array<{ gridIndex: number; weapon1Id: number }> = [];
for (let i = 0; i < len; i++) {
const mg = i === 0 ? anchor : map.getMapGrid(anchor.Row, anchor.Col + i);
if (!mg?.node?.isValid || !mg.node.active) return null;
if (!session.validator.isAllowedCampForPlace(mg.Index)) return null;
if (session.merge.map.getCellKind(mg.Index) !== MapCellKind.Put) return null;
if (MapDragGuideHelper.hasSpiderWeb(mg)) return null;
for (let o = 0; o < occupied.length; o++) {
if (occupied[o] === mg.Index) return null;
}
span.push(mg);
if (!session.merge.map.hasTile(mg.Index)) continue;
const blocker = this._resolveHeroDisplaceBlocker(session, mg.Index);
if (!blocker) return null;
blockers.push(blocker);
}
if (blockers.length <= 0) return null;
return { span, blockers };
}
/**
* 格上是否为可被神将挤走的单格单位(战斗字 / 营等无攻击;不可拆多格神将)。
* @param session 会话
* @param gridIndex 格
*/
private _resolveHeroDisplaceBlocker(session: BattleBagSession, gridIndex: number): { gridIndex: number; weapon1Id: number } | null {
const compose = this._findComposeAt(session, gridIndex);
if (compose && compose.gridIndices.length > 1) return null;
const tile = session.merge.map.getTile(gridIndex);
if (!tile) return null;
WeaponComposeConfigProvider.instance.load();
const w2Type = Number(WeaponComposeConfigProvider.instance.getWeapon2Type(tile.weapon2Id)) as Weapon2TileType;
if (w2Type !== Weapon2TileType.Single && w2Type !== Weapon2TileType.Combo) return null;
const w1 = this.resolveWeapon1Id(tile) ?? 0;
return { gridIndex, weapon1Id: w1 };
}
/**
* 挡路单格先挪到跨度外空位;战斗单位优先仍能打到路的格。
* @param session 会话
* @param map 地图
* @param span 目标跨度
* @param blockers 挡路单位
* @param heroOccupied 神将当前格(不可作停靠,尚未腾出)
*/
private _buildHeroBlockerParkMoves(
session: BattleBagSession,
map: MapManager,
span: readonly MapGrid[],
blockers: readonly { gridIndex: number; weapon1Id: number }[],
heroOccupied: readonly number[],
): Array<{ source: DragSource; target: DropTarget }> | null {
if (blockers.length <= 0) return [];
const parks = this._listHeroBlockerParks(session, map, span, heroOccupied);
if (parks.length < blockers.length) return null;
const usedPark: number[] = [];
const moves: Array<{ source: DragSource; target: DropTarget }> = [];
for (let b = 0; b < blockers.length; b++) {
const blocker = blockers[b];
let bestPark = -1;
let bestScore = -1e99;
for (let p = 0; p < parks.length; p++) {
if (usedPark.indexOf(p) >= 0) continue;
const park = parks[p];
let score = 0;
if (blocker.weapon1Id > 0 && !isNonCombatWeapon1(blocker.weapon1Id)) {
score = this.formationScore(map, park, blocker.weapon1Id);
// 优先可攻击格;没有覆盖时仍可暂停,避免神将永远让不出出口位
if (score <= -1e8) score = -1e7 - park.Index;
} else {
score = -park.Index;
}
if (score > bestScore || (score === bestScore && (bestPark < 0 || park.Index < parks[bestPark].Index))) {
bestScore = score;
bestPark = p;
}
}
if (bestPark < 0) return null;
usedPark.push(bestPark);
moves.push({
source: { kind: 'map', gridIndex: blocker.gridIndex },
target: { kind: 'map', gridIndex: parks[bestPark].Index },
});
}
return moves;
}
/**
* 神将重摆清桩空位(不在目标跨度、非神将当前格)。
* @param session 会话
* @param map 地图
* @param span 目标跨度
* @param heroOccupied 神将当前格
*/
private _listHeroBlockerParks(session: BattleBagSession, map: MapManager, span: readonly MapGrid[], heroOccupied: readonly number[]): MapGrid[] {
const out: MapGrid[] = [];
const grids = this._listActiveGrids(map);
for (let i = 0; i < grids.length; i++) {
const mg = grids[i];
if (!this.canDropTextOnEmptyGrid(session, mg, Weapon2TileType.Single)) continue;
if (this._isGridInSpan(mg.Index, span)) continue;
let onHero = false;
for (let h = 0; h < heroOccupied.length; h++) {
if (heroOccupied[h] === mg.Index) {
onHero = true;
break;
}
}
if (onHero) continue;
out.push(mg);
}
return out;
}
/**
* 无足够空位时:目标跨度满占且挡路数=神将格数 → 一一互换。
* @param occupied 神将格左→右
* @param span 目标跨度
* @param blockers 挡路(须覆盖整段)
*/
private _buildHeroSpanPairSwapMoves(
occupied: readonly number[],
span: readonly MapGrid[],
blockers: readonly { gridIndex: number; weapon1Id: number }[],
): Array<{ source: DragSource; target: DropTarget }> | null {
if (occupied.length !== span.length || blockers.length !== span.length) return null;
for (let i = 0; i < span.length; i++) {
let hit = false;
for (let b = 0; b < blockers.length; b++) {
if (blockers[b].gridIndex === span[i].Index) {
hit = true;
break;
}
}
if (!hit) return null;
}
const moves: Array<{ source: DragSource; target: DropTarget }> = [];
for (let i = 0; i < occupied.length; i++) {
if (occupied[i] === span[i].Index) continue;
moves.push({
source: { kind: 'map', gridIndex: occupied[i] },
target: { kind: 'map', gridIndex: span[i].Index },
});
}
return moves.length > 0 ? moves : null;
}
/**
* 单格战斗单位阵型优化:挪到更高阵型分的空位;**仅无空位时**才与营/凑不齐散字换位。
* @param session 会话
* @param map 地图
*/
private _findSingleFormationReposition(session: BattleBagSession, map: MapManager): { moves: Array<{ source: DragSource; target: DropTarget }> } | null {
const freeCombo = this._listFreeComboPieces(session, map);
const hasMultiHero = this.hasMultiCellHero(session);
const grids = this._listActiveGrids(map);
const empties: MapGrid[] = [];
const movers: Array<{ mg: MapGrid; w1: number; form: number }> = [];
const yielders: MapGrid[] = [];
for (let i = 0; i < grids.length; i++) {
const mg = grids[i];
if (!session.validator.isAllowedCampForPlace(mg.Index)) continue;
if (session.merge.map.getCellKind(mg.Index) !== MapCellKind.Put) continue;
if (!session.merge.map.hasTile(mg.Index)) {
empties.push(mg);
continue;
}
const tile = session.merge.map.getTile(mg.Index);
if (!tile) continue;
const compose = this._findComposeAt(session, mg.Index);
if (compose && compose.gridIndices.length > 1) continue;
WeaponComposeConfigProvider.instance.load();
const w2Type = Number(WeaponComposeConfigProvider.instance.getWeapon2Type(tile.weapon2Id)) as Weapon2TileType;
if (w2Type === Weapon2TileType.Single) {
const w1 = this.resolveWeapon1Id(tile) ?? 0;
if (w1 <= 0) continue;
if (isNonCombatWeapon1(w1)) {
yielders.push(mg);
continue;
}
// 有多格神将时:1–3 级单字不参与抢路边阵型,把火力位留给神将
const lv = Math.max(1, Math.floor(Number(tile.level) || 1));
if (hasMultiHero && lv <= 3) {
yielders.push(mg);
continue;
}
movers.push({
mg,
w1,
form: this.formationScore(map, mg, w1),
});
continue;
}
if (w2Type === Weapon2TileType.Combo && this._isOrphanComboPiece(freeCombo, tile.weapon2Id)) {
yielders.push(mg);
}
}
let bestGain = 0;
let bestMoves: Array<{ source: DragSource; target: DropTarget }> | null = null;
for (let u = 0; u < movers.length; u++) {
const from = movers[u];
for (let e = 0; e < empties.length; e++) {
const to = empties[e];
const score = this.formationScore(map, to, from.w1);
const gain = score - from.form;
if (gain <= bestGain) continue;
bestGain = gain;
bestMoves = [{
source: { kind: 'map', gridIndex: from.mg.Index },
target: { kind: 'map', gridIndex: to.Index },
}];
}
// 有空位时只挪空位,禁止与悟/营换位(避免「挤掉」一级普通)
if (empties.length > 0) continue;
for (let y = 0; y < yielders.length; y++) {
const to = yielders[y];
if (to.Index === from.mg.Index) continue;
const score = this.formationScore(map, to, from.w1);
const gain = score - from.form;
if (gain <= bestGain) continue;
bestGain = gain;
bestMoves = [{
source: { kind: 'map', gridIndex: from.mg.Index },
target: { kind: 'map', gridIndex: to.Index },
}];
}
}
if (!bestMoves || bestGain <= 0) return null;
return { moves: bestMoves };
}
/**
* 组合散字所属武将当前是否凑不齐(无法拼装成将)→ 可给战斗单位让位。
* @param freeCombo 自由组合字
* @param weapon2Id 该散字 Text.id
*/
private _isOrphanComboPiece(freeCombo: readonly FreeComboPiece[], weapon2Id: number): boolean {
const heroId = this.comboHeroIdForText(weapon2Id);
if (heroId == null) return true;
const hero = gg.data.table.getTableData<ITableHero>(TableNames.Hero, heroId);
if (!hero || !isComboHero(hero)) return true;
const group = hero.text;
if (!Array.isArray(group) || group.length < 2) return true;
return !this._hasAllGroupPieces(freeCombo, group);
}
/**
* 是否为可单独挪动的单格塔(非多格成将占格;仅普通字,避免拆组合字)。
* @param session 会话
* @param gridIndex 格
*/
private _isSingleCellMapUnit(session: BattleBagSession, gridIndex: number): boolean {
const compose = this._findComposeAt(session, gridIndex);
if (compose && compose.gridIndices.length > 1) return false;
const tile = session.merge.map.getTile(gridIndex);
if (!tile) return false;
WeaponComposeConfigProvider.instance.load();
const w2Type = Number(WeaponComposeConfigProvider.instance.getWeapon2Type(tile.weapon2Id)) as Weapon2TileType;
return w2Type === Weapon2TileType.Single;
}
/**
* 落点与 Hero.text 左→右字序贴合分。
* 成将最高;可落成横排跨度(空位/已正序)次之;逆序邻接强罚。
* @param session 会话
* @param map 地图
* @param weapon2Id 待落字
* @param mg 候选空格
*/
composeOrderScore(session: BattleBagSession, map: MapManager, weapon2Id: number, mg: MapGrid): number {
WeaponComposeConfigProvider.instance.load();
const heroes = gg.data.table.getTableList<ITableHero>(TableNames.Hero) ?? [];
let best = 0;
let wrong = false;
for (let h = 0; h < heroes.length; h++) {
const hero = heroes[h];
if (!hero || !isComboHero(hero)) continue;
const group = hero.text;
if (!Array.isArray(group) || group.length < 2) continue;
let pos = -1;
for (let i = 0; i < group.length; i++) {
if (Number(group[i]) === weapon2Id) {
pos = i;
break;
}
}
if (pos < 0) continue;
let partners = 0;
let blocked = false;
for (let i = 0; i < group.length; i++) {
if (i === pos) continue;
const nb = map.getMapGrid(mg.Row, mg.Col + (i - pos));
if (!nb?.node?.isValid) {
blocked = true;
break;
}
if (!session.validator.isAllowedCampForPlace(nb.Index)) {
blocked = true;
break;
}
const tile = session.merge.map.getTile(nb.Index);
if (!tile) continue;
if (tile.weapon2Id === Number(group[i])) partners++;
else {
blocked = true;
break;
}
}
if (!blocked) {
if (partners === group.length - 1) {
best = Math.max(best, 100000);
} else if (partners > 0) {
best = Math.max(best, 1000 + partners * 100);
}
}
// 以本格为 group[pos],评估整段横向 Put 是否可拼(空/正序/可挪挡)
const spanReady = this._comboSpanReadiness(session, map, group, pos, mg);
if (spanReady > best) best = spanReady;
// 逆序:本字左侧已是「本该在右侧」的搭档,或右侧已是「本该在左侧」的搭档
if (pos + 1 < group.length) {
const left = map.getMapGrid(mg.Row, mg.Col - 1);
const leftTile = left ? session.merge.map.getTile(left.Index) : null;
if (leftTile && leftTile.weapon2Id === Number(group[pos + 1])) wrong = true;
}
if (pos - 1 >= 0) {
const right = map.getMapGrid(mg.Row, mg.Col + 1);
const rightTile = right ? session.merge.map.getTile(right.Index) : null;
if (rightTile && rightTile.weapon2Id === Number(group[pos - 1])) wrong = true;
}
}
if (wrong && best < 100000) return -50000;
return best;
}
/**
* 组合字落在 mg 作为 group[pos] 时,横向整段可拼装程度。
* @param session 会话
* @param map 地图
* @param group Hero.text
* @param pos 本字在组内下标
* @param mg 候选格
*/
private _comboSpanReadiness(session: BattleBagSession, map: MapManager, group: readonly number[], pos: number, mg: MapGrid): number {
const len = group.length;
const anchorCol = mg.Col - pos;
let partners = 0;
let empties = 0;
let movable = 0;
for (let i = 0; i < len; i++) {
const cell = map.getMapGrid(mg.Row, anchorCol + i);
if (!cell?.node?.isValid || !cell.node.active) return 0;
if (!session.validator.isAllowedCampForPlace(cell.Index)) return 0;
if (session.merge.map.getCellKind(cell.Index) !== MapCellKind.Put) return 0;
if (MapDragGuideHelper.hasSpiderWeb(cell)) return 0;
if (i === pos) continue;
const tile = session.merge.map.getTile(cell.Index);
if (!tile) {
empties++;
continue;
}
const compose = this._findComposeAt(session, cell.Index);
if (compose && compose.gridIndices.length > 1) return 0;
if (tile.weapon2Id === Number(group[i])) {
partners++;
continue;
}
// 错位组字或普通单字:拼装可清走
movable++;
}
if (partners === len - 1) return 100000;
if (empties === len - 1) return 8000;
if (partners + empties === len - 1) return 5000 + partners * 500;
if (partners + empties + movable === len - 1) return 2000 + partners * 200;
return 0;
}
/**
* 落子后武将(多格+神将)预估:现有多格 + 若本字可单格成将。
* @param session 会话
* @param weapon2Id 待落字
*/
predictHeroCountAfterPlace(session: BattleBagSession, weapon2Id: number): number {
const base = this._multiHeroCount(session);
const w1 = this.resolveWeapon1Id({ weapon2Id });
if (w1 == null) return base;
const hero = gg.data.table.getTableData<ITableHero>(TableNames.Hero, w1);
if (hero && isComboHero(hero)) return base;
if (WeaponComposeConfigProvider.instance.isSingleCellWeapon1(w1)) return base + 1;
return base;
}
/**
* 橙/紫相对占格是否可互换(§6.4)。
* 优先级:2级普通 > 橙(4) > 紫(3) > 1级普通。神将默认不可被挤。
* @param incoming 新落字
* @param occupant 格上字
* @param session 可选:读组合体真实等级
* @param occupantGridIndex 可选:格索引
*/
canQualitySwap(incoming: TileLike, occupant: TileLike, session?: BattleBagSession | null, occupantGridIndex?: number): boolean {
WeaponComposeConfigProvider.instance.load();
const provider = WeaponComposeConfigProvider.instance;
const inCfg = provider.getWeapon2Config(incoming.weapon2Id);
const occCfg = provider.getWeapon2Config(occupant.weapon2Id);
if (!inCfg || !occCfg) return false;
if (session != null && occupantGridIndex != null) {
const compose = this._findComposeAt(session, occupantGridIndex);
if (compose && compose.gridIndices.length > 1) return false;
}
const occW1 = this.resolveWeapon1Id(occupant);
if (occW1 != null) {
const hero = gg.data.table.getTableData<ITableHero>(TableNames.Hero, occW1);
if (hero && isComboHero(hero)) return false;
}
// 组合散字(可拼神将)不可被橙/紫当普通字挤掉
if (this.comboHeroIdForText(occupant.weapon2Id) != null) return false;
const inQ = Number(inCfg.quality) || 0;
// 仅橙(4)/紫(3)可品质挤占;普通字由 AiSwapPlanner 探测时静默跳过
if (inQ !== 3 && inQ !== 4) return false;
const occType = Number(occCfg.type) || 0;
const occQ = Number(occCfg.quality) || 0;
const occLv = this._resolveOccupantLevel(occupant, session ?? null, occupantGridIndex);
let ok = false;
if (inQ === 4) {
// 橙可挤:1 级普通、紫色合成字(不可挤 2 级普通;橙仍高于紫)
if (occType === 1) ok = occLv <= 1;
else if (occType === 2 && occQ === 3) ok = true;
} else if (inQ === 3) {
// 紫可挤:仅 1 级普通(不可挤 2 级普通)
if (occType === 1) ok = occLv <= 1;
}
return ok;
}
/**
* 被挤目标档位分(越小越优先被橙/紫挤掉)。
* 1级普通=0 < 紫=1;不可挤返回 -1。
* @param incoming 新落字
* @param occupant 格上字
* @param session 会话
* @param occupantGridIndex 格索引
*/
qualitySwapTargetRank(incoming: TileLike, occupant: TileLike, session: BattleBagSession, occupantGridIndex: number): number {
if (!this.canQualitySwap(incoming, occupant, session, occupantGridIndex)) return -1;
WeaponComposeConfigProvider.instance.load();
const occCfg = WeaponComposeConfigProvider.instance.getWeapon2Config(occupant.weapon2Id);
if (!occCfg) return -1;
const occType = Number(occCfg.type) || 0;
const occQ = Number(occCfg.quality) || 0;
if (occType === 2 && occQ === 3) return 1;
const occLv = this._resolveOccupantLevel(occupant, session, occupantGridIndex);
if (occType === 1 && occLv <= 1) return 0;
return -1;
}
/**
* 格上单位局内等级:tile.level 与组合体等级取高。
* @param occupant 格上记录
* @param session 会话
* @param gridIndex 格索引
*/
private _resolveOccupantLevel(occupant: TileLike, session: BattleBagSession | null, gridIndex?: number): number {
let lv = Math.max(1, Math.floor(Number(occupant.level) || 1));
if (!session || gridIndex == null) return lv;
const tile = session.merge.map.getTile(gridIndex);
if (tile?.level != null) lv = Math.max(lv, Math.floor(Number(tile.level) || 1));
const compose = this._findComposeAt(session, gridIndex);
if (compose) lv = Math.max(lv, this.composeMaxLevel(session, compose), Math.floor(Number(compose.level) || 1));
return lv;
}
/**
* 神将换更低级神将。
* @param incoming 新落字
* @param occupant 格上字
*/
canHeroReplaceHero(incoming: TileLike, occupant: TileLike): boolean {
const inW1 = this.resolveWeapon1Id(incoming);
const occW1 = this.resolveWeapon1Id(occupant);
if (inW1 == null || occW1 == null) return false;
const inHero = gg.data.table.getTableData<ITableHero>(TableNames.Hero, inW1);
const occHero = gg.data.table.getTableData<ITableHero>(TableNames.Hero, occW1);
if (!inHero || !occHero) return false;
if (!isComboHero(inHero) || !isComboHero(occHero)) return false;
return (incoming.level ?? 1) > (occupant.level ?? 1);
}
private _collectTileIndices(merge: BattleBagSession['merge']): number[] {
const out: number[] = [];
const indices = merge.map.gridIndices;
for (let i = 0; i < indices.length; i++) {
if (merge.map.hasTile(indices[i])) out.push(indices[i]);
}
return out;
}
private _findComposeAt(session: BattleBagSession, gridIndex: number): ComposedUnit | null {
const comps = session.compositions;
for (let i = 0; i < comps.length; i++) {
if (comps[i].gridIndices.indexOf(gridIndex) >= 0) return comps[i];
}
return null;
}
/**
* 格上组合体(供互换等外部探测)。
* @param session 会话
* @param gridIndex 格
*/
findComposeAtGrid(session: BattleBagSession, gridIndex: number): ComposedUnit | null {
return this._findComposeAt(session, gridIndex);
}
private _listActiveGrids(map: MapManager): MapGrid[] {
const out: MapGrid[] = [];
const grids = map.mapGrids;
if (!Array.isArray(grids)) return out;
for (let i = 0; i < grids.length; i++) {
const mg = grids[i];
if (!mg?.node?.isValid || !mg.node.active) continue;
out.push(mg);
}
return out;
}
/**
* 场上合成武将(神将)数量。
* @param session 会话
*/
countComboHeroes(session: BattleBagSession): number {
return this._multiHeroCount(session);
}
private _multiHeroCount(session: BattleBagSession): number {
let n = 0;
const comps = session.compositions;
for (let i = 0; i < comps.length; i++) {
if (comps[i].gridIndices.length > 1) n++;
else {
const hero = gg.data.table.getTableData<ITableHero>(TableNames.Hero, comps[i].weapon1Id);
if (hero && isComboHero(hero)) n++;
}
}
return n;
}
/**
* 本方是否已有该多格神将(Hero.id / weapon1Id)。
* @param session 会话
* @param weapon1Id 神将 id
*/
private _hasMultiHeroWeapon1(session: BattleBagSession, weapon1Id: number): boolean {
if (!(weapon1Id > 0)) return false;
const comps = session.compositions;
for (let i = 0; i < comps.length; i++) {
const c = comps[i];
if (c.weapon1Id !== weapon1Id) continue;
if (c.gridIndices.length > 1) return true;
const hero = gg.data.table.getTableData<ITableHero>(TableNames.Hero, c.weapon1Id);
if (hero && isComboHero(hero)) return true;
}
return false;
}
private _exportSnapshot(session: BattleBagSession): BoardSnapshot {
const merge = session.merge;
const bagSlots: Array<BagSlotRecord | null> = [];
for (let i = 0; i < merge.bag.slotCount; i++) {
const slot = merge.bag.getSlot(i);
bagSlots.push(slot ? { ...slot } : null);
}
const mapCells: Array<{ gridIndex: number; cell: MapCellSnapshot }> = [];
for (let i = 0; i < merge.map.gridIndices.length; i++) {
const gridIndex = merge.map.gridIndices[i];
const cell = merge.map.exportCell(gridIndex);
if (cell) mapCells.push({ gridIndex, cell });
}
const mapTiles: Array<{ gridIndex: number; tile: MapTileRecord }> = [];
const tileIndices = this._collectTileIndices(merge);
for (let i = 0; i < tileIndices.length; i++) {
const gridIndex = tileIndices[i];
const tile = merge.map.getTile(gridIndex);
if (tile) mapTiles.push({ gridIndex, tile: { ...tile } });
}
const compositions: ComposedUnit[] = [];
for (let i = 0; i < merge.compositions.length; i++) {
const c = merge.compositions[i];
compositions.push({
weapon1Id: c.weapon1Id,
level: c.level,
gridIndices: c.gridIndices.slice(),
direction: c.direction,
});
}
return {
prepActive: merge.prepActive,
bagSlots,
mapCells,
mapTiles,
compositions,
};
}
private _dryRunIncreasesHeroes(session: BattleBagSession, source: DragSource, target: DropTarget, beforeHeroes: number): boolean {
const snapshot = this._exportSnapshot(session);
const reason = session.merge.drop(source, target, { silent: true });
let ok = false;
if (!reason) {
ok = this._multiHeroCount(session) > beforeHeroes;
}
session.merge.restoreBoardState(snapshot, { silent: true });
return ok;
}
/**
* 列出未并入多格武将的组合字(地图优先序 + 背包从左到右)。
* @param session 会话
* @param map 地图
*/
private _listFreeComboPieces(session: BattleBagSession, map: MapManager): FreeComboPiece[] {
const out = this._listFreeComboPiecesOnMap(session, map);
WeaponComposeConfigProvider.instance.load();
const slots = session.getBagSlots();
for (let s = 0; s < slots.length; s++) {
const slot = slots[s];
if (!slot) continue;
const w2Type = Number(WeaponComposeConfigProvider.instance.getWeapon2Type(slot.weapon2Id)) as Weapon2TileType;
if (w2Type !== Weapon2TileType.Combo) continue;
out.push({
weapon2Id: slot.weapon2Id,
source: { kind: 'bag', slotIndex: s },
mapGridIndex: -1,
});
}
return out;
}
/**
* 仅地图上的自由组合散字。
* @param session 会话
* @param map 地图
*/
private _listFreeComboPiecesOnMap(session: BattleBagSession, map: MapManager): FreeComboPiece[] {
WeaponComposeConfigProvider.instance.load();
const out: FreeComboPiece[] = [];
const grids = this._listActiveGrids(map);
for (let i = 0; i < grids.length; i++) {
const mg = grids[i];
if (!session.validator.isAllowedCampForPlace(mg.Index)) continue;
const tile = session.merge.map.getTile(mg.Index);
if (!tile) continue;
const w2Type = Number(WeaponComposeConfigProvider.instance.getWeapon2Type(tile.weapon2Id)) as Weapon2TileType;
if (w2Type !== Weapon2TileType.Combo) continue;
const compose = this._findComposeAt(session, mg.Index);
if (compose && compose.gridIndices.length > 1) continue;
out.push({
weapon2Id: tile.weapon2Id,
source: { kind: 'map', gridIndex: mg.Index },
mapGridIndex: mg.Index,
});
}
return out;
}
/**
* 背包首个空槽;无则 -1。
* @param session 会话
*/
private _findEmptyBagSlot(session: BattleBagSession): number {
const slots = session.getBagSlots();
for (let i = 0; i < slots.length; i++) {
if (!slots[i]) return i;
}
return -1;
}
/**
* 自由字是否覆盖 Hero.text 每一格所需 id。
* @param pieces 自由组合字
* @param group Hero.text
*/
private _hasAllGroupPieces(pieces: readonly FreeComboPiece[], group: readonly number[]): boolean {
const claimed: number[] = [];
for (let i = 0; i < group.length; i++) {
const need = Number(group[i]);
let hit = -1;
for (let p = 0; p < pieces.length; p++) {
if (claimed.indexOf(p) >= 0) continue;
if (pieces[p].weapon2Id !== need) continue;
hit = p;
break;
}
if (hit < 0) return false;
claimed.push(hit);
}
return true;
}
/**
* 为某一英雄挑选阵型更优(偏路径出口)、其次步数少的拼装落位。
* @param session 会话
* @param map 地图
* @param group Hero.text
* @param pieces 自由组合字
* @param weapon1Id 武将 id
*/
private _planAssembleHero(session: BattleBagSession, map: MapManager, group: readonly number[], pieces: readonly FreeComboPiece[], weapon1Id: number): AssembleHeroPlan | null {
const grids = this._listActiveGrids(map);
let best: AssembleHeroPlan | null = null;
for (let g = 0; g < grids.length; g++) {
const anchor = grids[g];
const built = this._tryBuildAssembleAtAnchor(session, map, anchor, group, pieces, weapon1Id);
if (!built) continue;
if (!best
|| built.formation > best.formation
|| (built.formation === best.formation && built.moves.length < best.moves.length)
|| (built.formation === best.formation && built.moves.length === best.moves.length && built.anchorIndex < best.anchorIndex)) {
best = built;
}
}
return best;
}
/**
* 自 anchor 起横向跨度拼装:空位/正序占位可直接用;可移动挡格先清走;多格武将占用则否。
* @param session 会话
* @param map 地图
* @param anchor 左锚
* @param group Hero.text
* @param pieces 自由组合字
* @param weapon1Id 武将 id
*/
private _tryBuildAssembleAtAnchor(session: BattleBagSession, map: MapManager, anchor: MapGrid, group: readonly number[], pieces: readonly FreeComboPiece[], weapon1Id: number): AssembleHeroPlan | null {
const len = group.length;
const span: MapGrid[] = [];
const alreadyOk: boolean[] = [];
const foreignBlockers: number[] = [];
for (let i = 0; i < len; i++) {
const mg = i === 0 ? anchor : map.getMapGrid(anchor.Row, anchor.Col + i);
if (!mg?.node?.isValid || !mg.node.active) return null;
if (!session.validator.isAllowedCampForPlace(mg.Index)) return null;
if (session.merge.map.getCellKind(mg.Index) !== MapCellKind.Put) return null;
if (MapDragGuideHelper.hasSpiderWeb(mg)) return null;
const tile = session.merge.map.getTile(mg.Index);
if (!tile) {
alreadyOk.push(false);
span.push(mg);
continue;
}
const compose = this._findComposeAt(session, mg.Index);
if (compose && compose.gridIndices.length > 1) return null;
if (tile.weapon2Id === Number(group[i])) {
alreadyOk.push(true);
span.push(mg);
continue;
}
alreadyOk.push(false);
span.push(mg);
if (!this._isGroupTextId(group, tile.weapon2Id)) {
foreignBlockers.push(mg.Index);
}
}
const usedPiece: number[] = [];
for (let i = 0; i < len; i++) {
if (!alreadyOk[i]) continue;
const need = Number(group[i]);
let hit = -1;
for (let p = 0; p < pieces.length; p++) {
if (usedPiece.indexOf(p) >= 0) continue;
if (pieces[p].weapon2Id !== need) continue;
if (pieces[p].mapGridIndex !== span[i].Index) continue;
hit = p;
break;
}
if (hit < 0) return null;
usedPiece.push(hit);
}
const assign: Array<FreeComboPiece | null> = [];
for (let i = 0; i < len; i++) {
if (alreadyOk[i]) {
assign.push(null);
continue;
}
const need = Number(group[i]);
const hit = this._claimAssemblePiece(pieces, usedPiece, need, span, alreadyOk);
if (hit < 0) return null;
usedPiece.push(hit);
assign.push(pieces[hit]);
}
const cycleNeed = this._assembleCycleParkNeed(alreadyOk, span, group, session);
const parks = this._listAssembleParkEmpties(session, map, span, foreignBlockers.length + cycleNeed);
let moves: Array<{ source: DragSource; target: DropTarget }> | null = null;
if (parks.length >= foreignBlockers.length + cycleNeed) {
const cycleParks = parks.slice(foreignBlockers.length);
moves = this._buildAssembleMoveList(span, alreadyOk, assign, foreignBlockers, parks, cycleParks);
} else {
// 无足够清桩空位:与挡路/错位直接互换(背包↔地图或地图↔地图),避免等刷新/铲地才成将
moves = this._buildAssembleSwapMoveList(span, alreadyOk, assign);
}
if (!moves || moves.length <= 0) return null;
return {
moves,
formation: this.formationScore(map, span[0], weapon1Id),
anchorIndex: span[0].Index,
rangePx: this.rangePxForWeapon1(weapon1Id),
};
}
/**
* Text.id 是否属于本组 Hero.text。
* @param group Hero.text
* @param weapon2Id Text.id
*/
private _isGroupTextId(group: readonly number[], weapon2Id: number): boolean {
const id = Number(weapon2Id);
for (let i = 0; i < group.length; i++) {
if (Number(group[i]) === id) return true;
}
return false;
}
/**
* 拼装清桩用空位(不在目标跨度内)。
* @param session 会话
* @param map 地图
* @param span 目标跨度
* @param need 需要个数
*/
private _listAssembleParkEmpties(session: BattleBagSession, map: MapManager, span: readonly MapGrid[], need: number): MapGrid[] {
if (need <= 0) return [];
const out: MapGrid[] = [];
const grids = this._listActiveGrids(map);
for (let i = 0; i < grids.length; i++) {
if (out.length >= need) break;
const mg = grids[i];
if (!this.canDropTextOnEmptyGrid(session, mg, Weapon2TileType.Single)) continue;
if (this._isGridInSpan(mg.Index, span)) continue;
out.push(mg);
}
return out;
}
/**
* 跨度内错位组字互相换位时需要的临时空位数(0 或 1)。
* @param alreadyOk 正序占位
* @param span 目标跨度
* @param group Hero.text
* @param session 会话
*/
private _assembleCycleParkNeed(alreadyOk: readonly boolean[], span: readonly MapGrid[], group: readonly number[], session: BattleBagSession): number {
let misplaced = 0;
for (let i = 0; i < span.length; i++) {
if (alreadyOk[i]) continue;
const tile = session.merge.map.getTile(span[i].Index);
if (!tile) continue;
if (this._isGroupTextId(group, tile.weapon2Id)) misplaced++;
}
return misplaced >= 2 ? 1 : 0;
}
/**
* 无清桩空位时:组字直接与目标格互换/落入(含背包字换下挡路单位)。
* 跨度内两字互为错位时只发一步互换。
* @param span 目标跨度
* @param alreadyOk 正序已占
* @param assign 各格认领字
*/
private _buildAssembleSwapMoveList(
span: readonly MapGrid[],
alreadyOk: readonly boolean[],
assign: ReadonlyArray<FreeComboPiece | null>,
): Array<{ source: DragSource; target: DropTarget }> | null {
const pending: Array<{ source: DragSource; targetIndex: number; fromGrid: number }> = [];
for (let i = 0; i < span.length; i++) {
if (alreadyOk[i]) continue;
const piece = assign[i];
if (!piece) return null;
const targetIndex = span[i].Index;
if (piece.mapGridIndex === targetIndex) continue;
pending.push({
source: piece.source,
targetIndex,
fromGrid: piece.mapGridIndex,
});
}
if (pending.length <= 0) return null;
const done: boolean[] = [];
for (let i = 0; i < pending.length; i++) done.push(false);
const moves: Array<{ source: DragSource; target: DropTarget }> = [];
for (let i = 0; i < pending.length; i++) {
if (done[i]) continue;
const a = pending[i];
let mutual = -1;
if (a.fromGrid >= 0) {
for (let j = i + 1; j < pending.length; j++) {
if (done[j]) continue;
const b = pending[j];
if (b.fromGrid === a.targetIndex && a.fromGrid === b.targetIndex) {
mutual = j;
break;
}
}
}
moves.push({
source: a.source,
target: { kind: 'map', gridIndex: a.targetIndex },
});
done[i] = true;
if (mutual >= 0) done[mutual] = true;
}
return moves.length > 0 ? moves : null;
}
/**
* 生成清桩 + 落子动作序列(目标空了才落,避免互相踩;错位互换可借临时空位)。
* @param span 目标跨度
* @param alreadyOk 是否已正序占位
* @param assign 各格认领的字(正序占位为 null)
* @param foreignBlockers 需清走的外族格
* @param parks 清桩空位(含周转位)
* @param cycleParks 错位互换临时空位
*/
private _buildAssembleMoveList(span: readonly MapGrid[], alreadyOk: readonly boolean[], assign: ReadonlyArray<FreeComboPiece | null>, foreignBlockers: readonly number[], parks: readonly MapGrid[], cycleParks: readonly MapGrid[]): Array<{ source: DragSource; target: DropTarget }> | null {
const moves: Array<{ source: DragSource; target: DropTarget }> = [];
for (let b = 0; b < foreignBlockers.length; b++) {
moves.push({
source: { kind: 'map', gridIndex: foreignBlockers[b] },
target: { kind: 'map', gridIndex: parks[b].Index },
});
}
const pending: Array<{ source: DragSource; target: { kind: 'map'; gridIndex: number }; fromGrid: number }> = [];
for (let i = 0; i < span.length; i++) {
if (alreadyOk[i]) continue;
const piece = assign[i];
if (!piece) return null;
pending.push({
source: piece.source,
target: { kind: 'map', gridIndex: span[i].Index },
fromGrid: piece.mapGridIndex,
});
}
const emptyMarks: number[] = [];
for (let i = 0; i < span.length; i++) {
if (alreadyOk[i]) continue;
let occupiedByPendingSource = false;
for (let p = 0; p < pending.length; p++) {
if (pending[p].fromGrid === span[i].Index) {
occupiedByPendingSource = true;
break;
}
}
// 空位或外族挡格(前面已排清桩,视为可落)
if (!occupiedByPendingSource) emptyMarks.push(span[i].Index);
}
const tempParks: number[] = [];
for (let i = 0; i < cycleParks.length; i++) {
tempParks.push(cycleParks[i].Index);
}
const left = pending.slice();
while (left.length > 0) {
let progressed = false;
for (let i = 0; i < left.length; i++) {
const step = left[i];
if (emptyMarks.indexOf(step.target.gridIndex) < 0) continue;
moves.push({ source: step.source, target: step.target });
if (step.fromGrid >= 0 && this._isGridInSpan(step.fromGrid, span)) {
emptyMarks.push(step.fromGrid);
}
const ti = emptyMarks.indexOf(step.target.gridIndex);
if (ti >= 0) emptyMarks.splice(ti, 1);
left.splice(i, 1);
progressed = true;
break;
}
if (progressed) continue;
// 跨度内错位死锁:先挪一枚到临时空位再继续
if (tempParks.length <= 0) return null;
let broke = false;
for (let i = 0; i < left.length; i++) {
const step = left[i];
if (step.fromGrid < 0 || !this._isGridInSpan(step.fromGrid, span)) continue;
const park = tempParks[0];
tempParks.splice(0, 1);
moves.push({
source: { kind: 'map', gridIndex: step.fromGrid },
target: { kind: 'map', gridIndex: park },
});
emptyMarks.push(step.fromGrid);
step.fromGrid = park;
step.source = { kind: 'map', gridIndex: park };
broke = true;
break;
}
if (!broke) return null;
}
return moves.length > 0 ? moves : null;
}
/**
* 为空位认领自由字:跨度内错位组字 → 跨度外地图 → 背包。
* @param pieces 自由字
* @param usedPiece 已占用下标
* @param need Text.id
* @param span 目标跨度
* @param alreadyOk 跨度正序占位
*/
private _claimAssemblePiece(pieces: readonly FreeComboPiece[], usedPiece: readonly number[], need: number, span: readonly MapGrid[], alreadyOk: readonly boolean[]): number {
for (let p = 0; p < pieces.length; p++) {
if (usedPiece.indexOf(p) >= 0) continue;
if (pieces[p].weapon2Id !== need) continue;
if (pieces[p].mapGridIndex < 0) continue;
const spanPos = this._spanIndexOf(pieces[p].mapGridIndex, span);
if (spanPos >= 0) {
if (alreadyOk[spanPos]) continue;
return p;
}
}
for (let p = 0; p < pieces.length; p++) {
if (usedPiece.indexOf(p) >= 0) continue;
if (pieces[p].weapon2Id !== need) continue;
if (pieces[p].mapGridIndex < 0) continue;
if (this._isGridInSpan(pieces[p].mapGridIndex, span)) continue;
return p;
}
for (let p = 0; p < pieces.length; p++) {
if (usedPiece.indexOf(p) >= 0) continue;
if (pieces[p].weapon2Id !== need) continue;
if (pieces[p].mapGridIndex >= 0) continue;
return p;
}
return -1;
}
/**
* 格在跨度中的下标;不在则 -1。
* @param gridIndex 格
* @param span 跨度
*/
private _spanIndexOf(gridIndex: number, span: readonly MapGrid[]): number {
for (let i = 0; i < span.length; i++) {
if (span[i].Index === gridIndex) return i;
}
return -1;
}
/**
* 格是否落在目标跨度内。
* @param gridIndex 格
* @param span 跨度
*/
private _isGridInSpan(gridIndex: number, span: readonly MapGrid[]): boolean {
return this._spanIndexOf(gridIndex, span) >= 0;
}
/**
* 按列升序排列同排合成格;跨行则返回 null。
* @param session 会话
* @param map 地图
* @param indices 合成格
*/
private _sortGridIndicesByCol(session: BattleBagSession, map: MapManager, indices: readonly number[]): number[] | null {
void session;
if (!indices || indices.length <= 0) return null;
const out: number[] = [];
for (let i = 0; i < indices.length; i++) out.push(indices[i]);
for (let i = 0; i < out.length; i++) {
for (let j = i + 1; j < out.length; j++) {
const a = map.getMapGridByIndex(out[i]);
const b = map.getMapGridByIndex(out[j]);
if (!a || !b) return null;
if (a.Row !== b.Row) return null;
if (b.Col < a.Col) {
const t = out[i];
out[i] = out[j];
out[j] = t;
}
}
}
const first = map.getMapGridByIndex(out[0]);
if (!first) return null;
for (let i = 1; i < out.length; i++) {
const mg = map.getMapGridByIndex(out[i]);
if (!mg || mg.Row !== first.Row || mg.Col !== first.Col + i) return null;
}
return out;
}
/**
* 自 anchor 起向右 len 格:须为本阵营 Put 且空(不得占用当前武将格)。
* @param session 会话
* @param map 地图
* @param anchor 左锚点
* @param len 字数
* @param occupied 当前武将格(禁止与目标重叠)
*/
private _buildEmptyHeroSpan(session: BattleBagSession, map: MapManager, anchor: MapGrid, len: number, occupied: readonly number[]): MapGrid[] | null {
const span: MapGrid[] = [];
for (let i = 0; i < len; i++) {
const mg = i === 0 ? anchor : map.getMapGrid(anchor.Row, anchor.Col + i);
if (!mg?.node?.isValid || !mg.node.active) return null;
if (!session.validator.isAllowedCampForPlace(mg.Index)) return null;
if (session.merge.map.getCellKind(mg.Index) !== MapCellKind.Put) return null;
if (MapDragGuideHelper.hasSpiderWeb(mg)) return null;
if (session.merge.map.hasTile(mg.Index)) return null;
for (let o = 0; o < occupied.length; o++) {
if (occupied[o] === mg.Index) return null;
}
span.push(mg);
}
return span;
}
/**
* 源格 → 目标格一一 map→map(前提:目标全空且不重叠)。
* @param src 源格(左→右)
* @param span 目标格
*/
private _buildNonOverlapMoves(src: readonly number[], span: readonly MapGrid[]): Array<{ source: DragSource; target: DropTarget }> | null {
if (src.length !== span.length || src.length <= 0) return null;
const moves: Array<{ source: DragSource; target: DropTarget }> = [];
for (let i = 0; i < src.length; i++) {
if (src[i] === span[i].Index) continue;
moves.push({
source: { kind: 'map', gridIndex: src[i] },
target: { kind: 'map', gridIndex: span[i].Index },
});
}
return moves.length > 0 ? moves : null;
}
}