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; 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(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(TableNames.Hero, weapon1Id); if (hero) { const skillId = heroNormalSkillId(hero, 1); const skill = skillId > 0 ? gg.data.table.getTableData(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(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(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(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(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(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(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(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(TableNames.Hero, inW1); const occHero = gg.data.table.getTableData(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(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(TableNames.Hero, c.weapon1Id); if (hero && isComboHero(hero)) return true; } return false; } private _exportSnapshot(session: BattleBagSession): BoardSnapshot { const merge = session.merge; const bagSlots: Array = []; 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 = []; 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, ): 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, 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; } }