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.
2099 lines
89 KiB
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;
|
|
}
|
|
}
|
|
|