You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
AdLike2048/assets/script/battle/bag/BattleBagSession.ts

1073 lines
43 KiB

import {
MapCellKind,
MergeEventFactory,
MergeEventType,
MergeSession,
PlaceRejectReason,
rejectReasonToMessage,
Weapon2TileType,
type BagSlotRecord,
type ComposedUnit,
type DragSource,
type DropTarget,
type MergeEvent,
type MergeEventListener,
type MergeEventSubscription,
} from 'db://assets/mx/module/merge';
import { GEvent } from 'db://assets/mx/module/event/GEvent';
import { Director, director } from 'cc';
import { createMembershipSet, membershipAdd } from '../../../mx/tools/MembershipSet';
import type { MapManager } from '../map/legacy/MapManager';
import type { MapGrid } from '../map/legacy/MapGrid';
import {
formatBagRollEntries,
formatIdList,
logBagRefreshDebug,
logBagRefreshSection,
} from '../debug/BagRefreshDebugLog';
import {
SHOVEL_WEAPON2_ID,
getDiscountedBagNormalRefreshCost,
} from './BagPrepConfig';
import { MapPlaceValidator } from './MapPlaceValidator';
import { MapDragGuideHelper } from '../view/MapDragGuideHelper';
import { WeaponComposeConfigProvider } from './WeaponComposeConfigProvider';
import { isAttackGlyph } from '../config/BattleHeroTable';
import { ComboLosePity, FirstBattleHeroPity } from './BagHeroRefreshAlgorithm';
import { PlayerBagTextPool } from './PlayerBagTextPool';
import type { PlayerBagTextPoolState } from './PlayerBagTextPool';
import type { BagRollEntry } from './BagWeaponPool';
import { buildGmWeaponPresetQueue } from '../debug/GmBattlePreset';
import { preloadGmHeroActorPrefabs } from '../heros/HeroActorRes';
import type { ProjectBattleHandle } from '../runtime/BattleRuntime';
import { RankDataManager } from '../../manager/RankDataManager';
import { DailyShopDataManager } from '../../manager/DailyShopDataManager';
import { resolveCarriedHeroSummonRatePoolAdjust, resolveCarriedSummonCostReduceWan } from '../../manager/battleBuff/BattleBuffPassiveSkillUtil';
import { BattleBuffCarryStore } from '../../manager/battleBuff/BattleBuffCarryStore';
import { getTowerLevelDownHost } from '../combat/TowerLevelDownHost';
import { getTowerRuinHost } from '../combat/TowerRuinHost';
import { getTowerExpHost } from '../combat/TowerExpHost';
import { findEntityIdByGridIndex } from '../runtime/DeployEntityQuery';
/** 项目层背包会话:桥接 MergeSession 与 MapManager */
export class BattleBagSession {
readonly merge: MergeSession;
readonly validator: MapPlaceValidator;
private readonly _map: MapManager;
private readonly _textPool = new PlayerBagTextPool();
/** 玩家连败组合字保底(AI 不使用) */
private readonly _comboPity = new ComboLosePity();
/** 新号第一局神将字保底(AI 不使用) */
private readonly _firstHeroPity: FirstBattleHeroPity;
/** 刷新扣桃所属 playerId */
private readonly _ownerPlayerId: number;
/** 本局已成功普通刷新次数(用于刷新消耗递增) */
private _normalRefreshUsed = 0;
/** PopGM 预设 Weapon1 → Weapon2 顺序队列(每次刷新从队首取,取完走正常逻辑) */
private _gmWeaponPresetQueue: number[] = [];
/** 视图层将自行播放 openSpine 的地图格(null=无) */
private _deferOpenFxGridIndex: number | null = null;
/** dropLogical(AI)跳过开挖 openSpine,只切格态 */
private _skipOpenEffect = false;
/** 本局携带被动:召唤消耗降低万分比(functiontype=26,开局快照) */
private readonly _summonCostReduceWan: number;
constructor(map: MapManager, chapterId: number, session?: import('db://assets/mx/module/battle').BattleSession, bagSlotCount = 5, opts?: {
/** 字池所用段位 id;缺省玩家当前段位 */
rankId?: number;
/** 扣桃所属玩家槽;缺省 0(本机) */
ownerPlayerId?: number;
}) {
this._map = map;
this._ownerPlayerId = Math.max(0, Math.floor(Number(opts?.ownerPlayerId) || 0));
this._firstHeroPity = new FirstBattleHeroPity(
this._ownerPlayerId === 0 && RankDataManager.hasNoBattleHistory(),
);
this.validator = new MapPlaceValidator(map, session, this._ownerPlayerId);
WeaponComposeConfigProvider.instance.load();
this._gmWeaponPresetQueue = buildGmWeaponPresetQueue();
preloadGmHeroActorPrefabs();
const rankId = opts?.rankId != null
? Math.floor(Number(opts.rankId) || 0)
: RankDataManager.getPlayerRankId();
// 携带被动按 ownerPlayerId:本机 DailyShop,AI 读 CarryStore
if (this._ownerPlayerId === 0) {
const heroSummonAdjust = resolveCarriedHeroSummonRatePoolAdjust(DailyShopDataManager.getOwnedPassive());
this._summonCostReduceWan = resolveCarriedSummonCostReduceWan(DailyShopDataManager.getOwnedPassive()).reduceWan;
this._textPool.beginBattle(rankId, {
heroSummonRateActive: heroSummonAdjust.active,
heroSummonRateOffset: heroSummonAdjust.offset,
});
} else {
const passives = BattleBuffCarryStore.getPassives(this._ownerPlayerId);
const heroSummonAdjust = resolveCarriedHeroSummonRatePoolAdjust(passives);
this._summonCostReduceWan = resolveCarriedSummonCostReduceWan(passives).reduceWan;
this._textPool.beginBattle(rankId, {
heroSummonRateActive: heroSummonAdjust.active,
heroSummonRateOffset: heroSummonAdjust.offset,
});
}
this.merge = new MergeSession({
config: WeaponComposeConfigProvider.instance,
pathChecker: this.validator,
bagSlotCount,
coordOf: (gridIndex) => {
const g = map.getMapGridByIndex(gridIndex);
if (!g) return null;
return { gridIndex, row: g.Row, col: g.Col };
},
});
this.merge.setMergeBlocker(this);
this.merge.on(MergeEventType.MapUpgraded, this._onRuinMapUpgraded);
this.merge.on(MergeEventType.BagMerged, this._onRuinBagMerged);
this._initMapCells();
this._map.areaUnlock.sync(this._map.mapGrids);
// 开局不自动 roll,玩家手动点刷新
this.merge.beginPrepRound();
}
/** 刷新扣桃所属 playerId */
get ownerPlayerId(): number {
return this._ownerPlayerId;
}
/**
* 合成升级解除牛魔王损毁。
* @param event 合成事件
*/
private readonly _onRuinMapUpgraded = (event: MergeEvent): void => {
if (event.type !== MergeEventType.MapUpgraded) return;
const tile = this.merge.map.getTile(event.gridIndex);
if (tile) tile.ruined = false;
getTowerRuinHost().releaseByUpgrade(this._ownerPlayerId, event.gridIndex);
};
/**
* 背包合成升级解除牛魔王损毁。
* @param event 合成事件
*/
private readonly _onRuinBagMerged = (event: MergeEvent): void => {
if (event.type !== MergeEventType.BagMerged) return;
const slot = this.merge.bag.getSlot(event.slotIndex);
if (slot) slot.ruined = false;
getTowerRuinHost().releaseBagUpgrade(this._ownerPlayerId, event.slotIndex);
};
/**
* 该格是否处于龟丞相降级禁合成。
* @param gridIndex 地图格
*/
isMergeUpgradeBlocked(gridIndex: number): boolean {
return getTowerLevelDownHost().isGridBlocked(this._ownerPlayerId, gridIndex);
}
get compositions(): readonly ComposedUnit[] {
return this.merge.compositions;
}
get normalRefreshCost(): number {
return getDiscountedBagNormalRefreshCost(this._normalRefreshUsed, this._summonCostReduceWan);
}
/** 字池是否还能再填满一次背包 */
canNormalRefreshFill(): boolean {
return this._textPool.canRollSlots(this.merge.bag.slotCount);
}
/** 广告刷新已下线 */
getAdRefreshMax(_chapterId: number): number {
return 0;
}
/** 广告刷新已下线 */
getAdRefreshRemaining(_chapterId: number): number {
return 0;
}
on(event: MergeEventSubscription, listener: MergeEventListener): void {
this.merge.on(event, listener);
}
off(event: MergeEventSubscription, listener: MergeEventListener): void {
this.merge.off(event, listener);
}
beginPrepRound(): void {
this.merge.beginPrepRound();
}
endPrepRound(): void {
this.merge.endPrepRound();
}
/**
* 普通刷新重 roll 背包。
* @param _levelId 保留签名兼容调用方
* @param _unused 保留签名兼容调用方
*/
refreshBag(_levelId: number, _unused = false): void {
const gmIds = (gg.game as { gmBagWeapon2Ids?: number[] | null }).gmBagWeapon2Ids;
const refreshIndex = this._normalRefreshUsed + 1;
const slotCount = this.merge.bag.slotCount;
const config = WeaponComposeConfigProvider.instance;
const gmPresetAttempt = !gmIds?.length
? this._peekGmWeaponPresetQueue(slotCount)
: [];
const hasGmPreset = gmPresetAttempt.length > 0;
logBagRefreshSection(`背包普通刷新 第${refreshIndex}次`);
logBagRefreshDebug(
'刷新参数',
gmIds?.length ? `GM=${formatIdList(gmIds)}` : 'GM=关',
hasGmPreset ? `预设队列=${formatIdList(gmPresetAttempt)}` : '',
`N=${this._textPool.numberN}`,
);
let slots: { weapon2Id: number; weapon1Id?: number; level?: number }[] = [];
const firstRefresh = this._normalRefreshUsed === 0;
if (gmIds?.length) {
for (let i = 0; i < slotCount; i++) {
const id = gmIds[i % gmIds.length];
slots.push({ weapon2Id: id });
this._textPool.tryConsumeWeapon2Id(id);
}
logBagRefreshDebug('GM固定池', formatBagRollEntries(slots, config));
} else if (hasGmPreset) {
for (let i = 0; i < gmPresetAttempt.length && slots.length < slotCount; i++) {
const id = gmPresetAttempt[i];
slots.push({ weapon2Id: id });
this._textPool.tryConsumeWeapon2Id(id);
}
const need = slotCount - slots.length;
if (need > 0) {
const needAtk = firstRefresh && !this._slotsHaveAttacker(slots);
const rolled = this._textPool.rollSlots(need, needAtk);
if (!rolled) {
throw new Error('[BattleBagSession] 字池耗尽,无法完成 GM 预设补齐');
}
for (let i = 0; i < rolled.length; i++) slots.push(rolled[i]);
}
this._commitGmWeaponPresetConsumption(gmPresetAttempt, slots);
logBagRefreshDebug('GM预设+字池', formatBagRollEntries(slots, config));
} else {
if (!this._textPool.ready) {
throw new Error('[BattleBagSession] 字池未就绪');
}
const rolled = this._textPool.rollSlots(slotCount, firstRefresh, false);
if (!rolled) {
throw new Error('[BattleBagSession] 字池耗尽,刷新失败');
}
slots = rolled;
if (this._ownerPlayerId === 0) {
this._firstHeroPity.applyToSlots(slots, this._normalRefreshUsed, this._textPool);
this._comboPity.applyToSlots(slots, this._collectOwnerMapComboIds(), this._textPool);
}
if (firstRefresh) this._textPool.shuffleEntries(slots);
}
logBagRefreshDebug('最终背包', formatBagRollEntries(slots, config));
logBagRefreshSection('背包普通刷新结束');
this.merge.refreshBag(slots);
}
/** 本批是否已有可攻击单字 */
private _slotsHaveAttacker(slots: readonly { weapon2Id: number }[]): boolean {
for (let i = 0; i < slots.length; i++) {
if (isAttackGlyph(slots[i].weapon2Id)) return true;
}
return false;
}
/** 己方地图上的组合碎字 Text.id(即将刷掉的旧背包不算) */
private _collectOwnerMapComboIds(): number[] {
const ids: number[] = [];
const camp = this._ownerPlayerId + 1;
const provider = WeaponComposeConfigProvider.instance;
const indices = this.merge.map.gridIndices;
for (let i = 0; i < indices.length; i++) {
const gridIndex = indices[i];
const tile = this.merge.map.getTile(gridIndex);
if (!tile) continue;
const grid = this._map.getMapGridByIndex(gridIndex);
if (!grid?.node?.isValid || !grid.node.active) continue;
if ((grid.CampAreaID | 0) !== camp) continue;
if (provider.getWeapon2Type(tile.weapon2Id) !== Weapon2TileType.Combo) continue;
ids.push(tile.weapon2Id);
}
return ids;
}
/** 预览 PopGM 预设队列队首(不消耗) */
private _peekGmWeaponPresetQueue(maxCount: number): number[] {
if (maxCount <= 0 || this._gmWeaponPresetQueue.length === 0) return [];
return this._gmWeaponPresetQueue.slice(0, maxCount);
}
/**
* 仅当本次刷新结果的前缀与预设一致时才 dequeue。
* 未成功刷入背包的预设字保留在队首,下次刷新重试。
*/
private _commitGmWeaponPresetConsumption(attempted: readonly number[], slots: readonly { weapon2Id: number }[]): void {
if (!attempted.length || !this._gmWeaponPresetQueue.length) return;
let consumed = 0;
for (let i = 0; i < attempted.length; i++) {
const slot = slots[i];
if (!slot || slot.weapon2Id !== attempted[i]) break;
consumed++;
}
if (consumed <= 0) {
logBagRefreshDebug('GM预设队列: 本次未消耗', formatIdList(this._gmWeaponPresetQueue));
return;
}
this._gmWeaponPresetQueue.splice(0, consumed);
logBagRefreshDebug(
'GM预设队列: 已消耗',
`${consumed}个`,
`剩余=${this._gmWeaponPresetQueue.length}`,
this._gmWeaponPresetQueue.length ? formatIdList(this._gmWeaponPresetQueue) : '',
);
}
/** 普通刷新:按 Const 扣桃子后重 roll 背包 */
tryNormalRefresh(chapterId: number, battle: ProjectBattleHandle): 'ok' | 'not_enough' | 'failed' | 'pool_empty' {
const slot = battle.session.players[this._ownerPlayerId] ?? (this._ownerPlayerId === 0 ? battle.localSlot : undefined);
if (!slot) {
console.log('[BattleBagSession] tryNormalRefresh: player slot missing', this._ownerPlayerId);
return 'failed';
}
if (!this.canNormalRefreshFill()) {
return 'pool_empty';
}
const cost = this.normalRefreshCost;
if (slot.gold < cost) return 'not_enough';
if (!slot.spendGold(cost)) return 'not_enough';
if (this._ownerPlayerId === 0) {
battle.CurentCoinNum = slot.gold;
}
try {
this.refreshBag(chapterId);
this._normalRefreshUsed++;
return 'ok';
} catch (e) {
slot.addGold(cost);
if (this._ownerPlayerId === 0) {
battle.CurentCoinNum = slot.gold;
}
console.error('[BattleBagSession] refreshBag failed', String(e));
return 'failed';
}
}
/**
* AI 逻辑落子:只改无头 Merge,不走底栏拖拽表现 / GEvent。
* 仍须同步 MapGrid 格语义(铲 Dig→Put 等),否则引导/落子探测与逻辑层脱节。
* @param source 拖拽源
* @param target 目标
*/
dropLogical(source: DragSource, target: DropTarget): PlaceRejectReason | null {
if (target.kind === 'map' && !this.validator.isAllowedCampForPlace(target.gridIndex)) {
return PlaceRejectReason.WrongCampArea;
}
this._snapshotHeroBindIfMapSource(source);
const before = this._snapshotWalkKinds(source, target);
this._skipOpenEffect = true;
let reason: PlaceRejectReason | null = null;
try {
reason = this.merge.drop(source, target, { silent: true });
if (reason) return reason;
this._syncChangedCellVisuals(source, target);
this._notifyWalkabilityIfNeeded(source, target, before);
} finally {
this._skipOpenEffect = false;
}
return null;
}
/**
* 文昌笔:按局内字池单抽 1 个字(与刷新召唤规则一致)。
* @param excludeWeapon2Id 尽量排除当前字
*/
rollRewriteGlyph(excludeWeapon2Id: number): BagRollEntry | null {
const rolled = this._textPool.rollSingleGlyph(excludeWeapon2Id);
if (!rolled) {
console.log('[BattleBagSession] 改字 roll 失败 exclude=', excludeWeapon2Id);
}
return rolled;
}
/** 是否还能改字 roll 出与当前不同的字 */
canRollRewriteGlyph(excludeWeapon2Id: number): boolean {
return this._textPool.canRollSingleGlyph(excludeWeapon2Id);
}
/** 广告刷新已下线 */
tryAdRefresh(_chapterId: number): boolean {
console.log('[BattleBagSession] 广告刷新已下线');
return false;
}
/** 免费格子已下线 */
grantFreeGrid(_chapterId: number): PlaceRejectReason | null {
console.log('[BattleBagSession] 新产品不支持放置格子');
return PlaceRejectReason.WrongCellKind;
}
/** 发放铲:优先背包空位,否则从下往上对第一个可开挖格直接使用 */
grantShovel(_chapterId: number): PlaceRejectReason | null {
if (!this.merge.prepActive) return PlaceRejectReason.NotPrepPhase;
if (this.merge.grantGridInBag(SHOVEL_WEAPON2_ID)) {
return null;
}
const gridIndex = this._findBottomUpDigGrid();
if (gridIndex == null) {
return PlaceRejectReason.BagFull;
}
const reason = this.merge.placeShovelOnMap(gridIndex, SHOVEL_WEAPON2_ID);
if (reason) return reason;
// Dig→Put 不改可走性:只同步格表现,禁止 notifyWalk 重寻路
this._syncGridVisual(gridIndex);
return null;
}
/**
* 蟠桃树被动落点:优先空 Dig,没有再选空 Put(自下而上)。
* @returns 地图格索引;无空 Dig/Put 时 null
*/
findPassiveDigSpawnGrid(excludeGridIndices?: readonly number[]): number | null {
const digs = this._collectPassiveDigSpawnCandidates(excludeGridIndices);
if (digs.length > 0) return digs[0]?.Index ?? null;
const puts = this._collectEmptyPutSpawnCandidates(excludeGridIndices);
return puts[0]?.Index ?? null;
}
/** 查找背包首个空槽 */
findEmptyBagSlot(excludeSlotIndices?: readonly number[]): number | null {
if (!this.merge.prepActive) return null;
const slotCount = this.merge.bag.slotCount;
for (let i = 0; i < slotCount; i++) {
if (excludeSlotIndices && excludeSlotIndices.indexOf(i) >= 0) continue;
if (this.merge.bag.getSlot(i)) continue;
return i;
}
return null;
}
/**
* 查找己方首个空 Put 格(妙手神偷落点;优先于背包)。
* 始终按 ownerPlayerId 对应 CampArea(0→1、1→2)过滤,不依赖 placeValidation 开关。
* @returns 地图格索引;无空 Put 时 null
*/
findStealReceiveMapGrid(): number | null {
const camp = this._ownerPlayerId + 1;
for (const grid of this._map.mapGrids) {
if (!grid?.node?.isValid || !grid.node.active) continue;
if ((grid.CampAreaID | 0) !== camp) continue;
if (this.merge.map.getCellKind(grid.Index) !== MapCellKind.Put) continue;
if (this.merge.map.hasTile(grid.Index)) continue;
return grid.Index;
}
return null;
}
/** 己方是否存在可放置文字的空 Put 格(与 MapDragGuideHelper / 拖拽 + 一致) */
hasLocalPlaceablePutGrid(): boolean {
return this.countLocalPlaceablePutGrids() > 0;
}
/** 己方阵营可放置文字的空 Put 格数量 */
countLocalPlaceablePutGrids(): number {
const camp = this._ownerPlayerId + 1;
return MapDragGuideHelper.countLocalEmptyPutGrids(this, this._map, camp);
}
/** 己方背包是否仍有字块可落到地图(空 Put / 升级 / 交换,与拉线落子一致) */
hasLocalBagMapPlacement(): boolean {
const camp = this._ownerPlayerId + 1;
return MapDragGuideHelper.hasLocalCampBagPlacement(this, this._map, camp);
}
/** 己方阵营地图无单字/组合字可落空格(含无可放格场景) */
isLocalCampMapPutSlotsFullForText(): boolean {
return !this.hasLocalPlaceablePutGrid();
}
/** @deprecated 使用 countLocalPlaceablePutGrids */
countLocalEmptyPutGrids(): number {
return this.countLocalPlaceablePutGrids();
}
/** 己方背包 + 地图上的铲子数量 */
countLocalShovelTiles(): number {
let n = 0;
const slots = this.getBagSlots();
for (let i = 0; i < slots.length; i++) {
const slot = slots[i];
if (slot?.weapon2Id === SHOVEL_WEAPON2_ID) n++;
}
const camp = this._ownerPlayerId + 1;
this.merge.map.forEachTile((gridIndex, tile) => {
const grid = this._map.getMapGridByIndex(gridIndex);
if (!grid?.node?.isValid || !grid.node.active) return;
if ((grid.CampAreaID | 0) !== camp) return;
if (tile.weapon2Id === SHOVEL_WEAPON2_ID) n++;
});
return n;
}
/** 是否可接收偷取字块(地图空 Put 或背包空槽) */
canReceiveStolenTile(): boolean {
return this.findStealReceiveMapGrid() != null || this.findEmptyBagSlot() != null;
}
/**
* 妙手神偷落点:优先地图空 Put,否则背包空槽。
* @returns 落点描述;无空位时 null
*/
resolveStealReceivePlacement(): { kind: 'map'; gridIndex: number } | { kind: 'bag'; slotIndex: number } | null {
const mapGrid = this.findStealReceiveMapGrid();
if (mapGrid != null) return { kind: 'map', gridIndex: mapGrid };
const slot = this.findEmptyBagSlot();
if (slot != null) return { kind: 'bag', slotIndex: slot };
return null;
}
/**
* 随机查找己方空的可开挖格。
* @param excludeGridIndices 飞行中已预留的挖格(同帧多铲互不抢同一格)
* @returns 地图格索引;无候选时 null
*/
findRandomPassiveDigGrid(excludeGridIndices?: readonly number[]): number | null {
const candidates = this._collectPassiveDigSpawnCandidates(excludeGridIndices);
if (candidates.length <= 0) return null;
const pick = Math.floor(Math.random() * candidates.length);
return candidates[pick]?.Index ?? null;
}
/** 被动铲:放入指定背包空槽 */
grantShovelInBag(slotIndex: number): PlaceRejectReason | null {
return this.merge.grantWeaponInBagSlot(slotIndex, SHOVEL_WEAPON2_ID);
}
/** 被动铲:对指定可开挖格使用(Dig→Put) */
grantPassiveShovelOnDigGrid(gridIndex: number): PlaceRejectReason | null {
if (!this.merge.prepActive) return PlaceRejectReason.NotPrepPhase;
const reason = this.merge.placeShovelOnMap(gridIndex, SHOVEL_WEAPON2_ID);
if (reason) return reason;
// Dig→Put:只同步表现,不重寻路
this._syncGridVisual(gridIndex);
GEvent.Ins.emit(GEvent.UpdateBestPlace);
return null;
}
/** 视图层将自行播放开挖链(铲 animaction)或被动产字直落时标记,跳过 playOpenEffect */
deferOpenEffectForGrid(gridIndex: number): void {
this._deferOpenFxGridIndex = Math.floor(Number(gridIndex) || 0);
}
/** 取消 deferOpenEffectForGrid(drop 失败时) */
clearDeferredOpenEffect(): void {
this._deferOpenFxGridIndex = null;
}
/** 己方空 Put 格候选(自下而上,蟠桃树 Dig 用尽后的落点) */
private _collectEmptyPutSpawnCandidates(excludeGridIndices?: readonly number[]): MapGrid[] {
const candidates: MapGrid[] = [];
for (const grid of this._map.mapGrids) {
if (!grid?.node?.isValid) continue;
if (!this.validator.isOwnerCampCell(grid.Index)) continue;
if (excludeGridIndices && excludeGridIndices.indexOf(grid.Index) >= 0) continue;
if (this.merge.map.getCellKind(grid.Index) !== MapCellKind.Put) continue;
if (this.merge.map.hasTile(grid.Index)) continue;
candidates.push(grid);
}
candidates.sort((a, b) => {
if (b.Row !== a.Row) return b.Row - a.Row;
return a.Col - b.Col;
});
return candidates;
}
/** 己方空的可开挖格候选(自下而上排序,供首格/随机选取) */
private _collectPassiveDigSpawnCandidates(excludeGridIndices?: readonly number[]): MapGrid[] {
const candidates: MapGrid[] = [];
for (const grid of this._map.mapGrids) {
if (!grid?.node?.isValid) continue;
if (!this.validator.isOwnerCampCell(grid.Index)) continue;
if (excludeGridIndices && excludeGridIndices.indexOf(grid.Index) >= 0) continue;
const kind = this.merge.map.getCellKind(grid.Index);
if (kind !== MapCellKind.Dig) continue;
if (this.merge.map.hasTile(grid.Index)) continue;
candidates.push(grid);
}
candidates.sort((a, b) => {
if (b.Row !== a.Row) return b.Row - a.Row;
return a.Col - b.Col;
});
return candidates;
}
/**
* 被动 buff:在空 Dig/Put 生成字块。
* 蟠桃树(102)落到 Put 则 Put→Dig;其余字块 Dig→Put(defer 跳过 fillRange)。
* @param gridIndex 目标格
* @param weapon2Id Weapon2 文字 id
*/
grantPassiveTileOnDigGrid(gridIndex: number, weapon2Id: number): PlaceRejectReason | null {
if (!this.validator.isOwnerCampCell(gridIndex)) {
return PlaceRejectReason.WrongCampArea;
}
WeaponComposeConfigProvider.instance.load();
const keepDigCell = WeaponComposeConfigProvider.instance.passiveSpawnKeepsDigCell(weapon2Id);
if (!keepDigCell) {
this.deferOpenEffectForGrid(gridIndex);
}
const reason = this.merge.grantPassiveTileOnDigGrid(gridIndex, weapon2Id);
if (reason) {
if (!keepDigCell) {
this.clearDeferredOpenEffect();
}
return reason;
}
// Dig→Put:_syncGridVisual 内 seal;Dig 本不可走,勿再走 _onWalkability 重寻路
this._syncGridVisual(gridIndex);
GEvent.Ins.emit(GEvent.UpdateBestPlace);
return null;
}
pickupFromMap(gridIndex: number) {
const result = this.merge.pickupFromMap(gridIndex);
if (!('error' in result) && result.wasType3) {
// type3 拖起:格恢复可行走,只重寻路,不驱逐
this._onWalkabilityCellChanged(gridIndex, false);
}
return result;
}
revertType3Pickup(gridIndex: number, tile: Parameters<MergeSession['revertType3Pickup']>[1]): void {
this.merge.revertType3Pickup(gridIndex, tile);
this._onWalkabilityCellChanged(gridIndex, true);
}
drop(source: DragSource, target: DropTarget): PlaceRejectReason | null {
if (target.kind === 'map' && !this.validator.isAllowedCampForPlace(target.gridIndex)) {
this.merge.reject(PlaceRejectReason.WrongCampArea);
return PlaceRejectReason.WrongCampArea;
}
if (target.kind === 'map') {
const unlockHit = this._dragUnlockArea(source, target.gridIndex);
if (unlockHit !== 'skip') return unlockHit;
}
this._snapshotHeroBindIfMapSource(source);
const before = this._snapshotWalkKinds(source, target);
const reason = this.merge.drop(source, target);
if (reason) {
this.merge.reject(reason);
return reason;
}
this._syncChangedCellVisuals(source, target);
this._notifyWalkabilityIfNeeded(source, target, before);
GEvent.Ins.emit(GEvent.UpdateBestPlace);
return null;
}
/**
* 文字拖到当前解锁区域:累计次数,满了就把该区域改成可放置。字本身不落下。
* @param source 拖拽源
* @param gridIndex 落点
*/
private _dragUnlockArea(source: DragSource, gridIndex: number): PlaceRejectReason | 'skip' {
const grid = this.validator.getGridByIndex(gridIndex);
if (!grid?.isAreaUnlock) return 'skip';
const weapon2Id = this._sourceWeapon2Id(source);
if (weapon2Id <= 0) return 'skip';
WeaponComposeConfigProvider.instance.load();
const w2Type = Number(WeaponComposeConfigProvider.instance.getWeapon2Type(weapon2Id)) as Weapon2TileType;
if (w2Type !== Weapon2TileType.Single && w2Type !== Weapon2TileType.Combo) return 'skip';
const hit = this._map.areaUnlock.tryDrag(grid);
if (hit === 'skip') return 'skip';
if (hit === 'blocked') return PlaceRejectReason.WrongCellKind;
if (hit === 'unlocked') {
const opened = this._map.areaUnlock.gridsOf(grid.unlockAreaId);
for (let i = 0; i < opened.length; i++) {
const cell = opened[i];
this.validator.applyCellKind(cell, MapCellKind.Put);
this.merge.map.markCellAsPut(cell.Index);
}
}
this._map.areaUnlock.applyVisual();
return PlaceRejectReason.UnlockDrag;
}
/**
* 拖拽源上的文字 id。
* @param source 拖拽源
*/
private _sourceWeapon2Id(source: DragSource): number {
if (source.kind === 'bag') {
return this.merge.bag.getSlot(source.slotIndex)?.weapon2Id ?? 0;
}
return this.merge.map.getTile(source.gridIndex)?.weapon2Id ?? 0;
}
/**
* 字块离开地图前,把当前英雄状态写到字上(进背包后仍能恢复)。
* @param source 拖拽源
*/
private _snapshotHeroBindIfMapSource(source: DragSource): void {
if (source.kind !== 'map') return;
const battle = gg.game.CurentBattle;
if (!battle) return;
const player = battle.getPlayer(this._ownerPlayerId);
if (!player) return;
const entityId = findEntityIdByGridIndex(source.gridIndex, battle.session, this._map, player);
if (entityId == null) return;
getTowerExpHost().syncBindToGlyphs(battle.session, player, this._map, entityId);
}
/**
* 仅同步 MapGrid ↔ MX 可走性(不重寻路)。
* 普通放字/合成不调用;开战/雷劈等需要全量对齐时用。
*/
resyncWalkabilityFromMap(): boolean {
return this.validator.resyncWalkabilityFromMap();
}
/**
* 可走性真变时:同步 + 重建寻路场 + 只刷新路线。
* @deprecated 外部请改用具体场景 API;保留给开战/雷劈兼容
*/
resyncWalkabilityAndRefreshPaths(force = false): void {
const changed = this.resyncWalkabilityFromMap();
if (force || changed) {
this.validator.notifyWalkStateChanged(true);
}
}
private _snapshotWalkKinds(source: DragSource, target: DropTarget): Map<number, MapCellKind | null> {
const out = new Map<number, MapCellKind | null>();
const add = (idx: number) => out.set(idx, this.merge.map.getCellKind(idx));
if (source.kind === 'map') add(source.gridIndex);
if (target.kind === 'map') add(target.gridIndex);
return out;
}
private _syncChangedCellVisuals(source: DragSource, target: DropTarget): void {
const indices = createMembershipSet<number>();
if (source.kind === 'map') membershipAdd(indices, source.gridIndex);
if (target.kind === 'map') membershipAdd(indices, target.gridIndex);
for (const idx of indices.keys()) this._syncGridVisual(idx);
}
/**
* 仅当格语义导致可走性变化时(type3 / 铲)才同步 MX 并重寻路。
* 往可放置格放字:可走性不变 → 完全不碰怪物。
*/
private _notifyWalkabilityIfNeeded(
source: DragSource,
target: DropTarget,
before: Map<number, MapCellKind | null>,
): void {
const indices = createMembershipSet<number>();
if (source.kind === 'map') membershipAdd(indices, source.gridIndex);
if (target.kind === 'map') membershipAdd(indices, target.gridIndex);
for (const idx of indices.keys()) {
const prev = before.get(idx) ?? null;
const next = this.merge.map.getCellKind(idx);
if (!this._walkabilityChanged(prev, next)) continue;
// 可行走 → 不可走:先赶走该格怪物,再改寻路
const becameBlocked = prev === MapCellKind.Walkable && next !== MapCellKind.Walkable;
this._onWalkabilityCellChanged(idx, becameBlocked);
}
}
private _walkabilityChanged(prev: MapCellKind | null, next: MapCellKind | null): boolean {
const walk = (k: MapCellKind | null) => k === MapCellKind.Walkable;
return walk(prev) !== walk(next);
}
/**
* @param evictHere type3 落子把格堵死时,只驱逐该格上的怪
*/
private _onWalkabilityCellChanged(gridIndex: number, evictHere: boolean): void {
this._syncGridVisual(gridIndex);
const grid = this.validator.getGridByIndex(gridIndex);
if (!grid) return;
// 单格同步到 MX(不靠 hasTile 挡路)
this.validator.resyncWalkabilityFromMap();
if (evictHere) {
this.validator.session?.movement.evictEnemiesAtCell(grid.Row, grid.Col);
}
this.validator.notifyWalkStateChanged(true);
}
private _findBottomUpDigGrid(): number | null {
const candidates: MapGrid[] = [];
for (const grid of this._map.mapGrids) {
if (!grid?.node?.isValid) continue;
const kind = this.merge.map.getCellKind(grid.Index);
if (kind !== MapCellKind.Dig) continue;
if (this.merge.map.hasTile(grid.Index)) continue;
candidates.push(grid);
}
candidates.sort((a, b) => {
if (b.Row !== a.Row) return b.Row - a.Row;
return a.Col - b.Col;
});
return candidates[0]?.Index ?? null;
}
private _findBottomUpWalkableGrid(): number | null {
const candidates: MapGrid[] = [];
for (const grid of this._map.mapGrids) {
if (!grid?.node?.isValid) continue;
const kind = this.merge.map.getCellKind(grid.Index);
if (kind !== MapCellKind.Walkable) continue;
if (!grid.isCanPutGrid) continue;
if (this.merge.map.hasTile(grid.Index)) continue;
candidates.push(grid);
}
candidates.sort((a, b) => {
if (b.Row !== a.Row) return b.Row - a.Row;
return a.Col - b.Col;
});
for (const grid of candidates) {
if (this.validator.canPlaceType3Grid(grid.Index)) {
return grid.Index;
}
}
return null;
}
private _initMapCells(): void {
for (const grid of this._map.mapGrids) {
if (!grid) continue;
const snap = this.validator.snapshotCell(grid);
this.merge.map.initCell(grid.Index, snap);
}
}
/**
* 将对端 merge 的己方阵营空格 kind 对齐到本格(蟠桃/铲)。
* 对端有字或非己方阵营则跳过,避免把已放置字刷成开挖皮。
* @param peer 对端 bag
* @param gridIndex 格
* @param kind 落子后 kind
*/
alignPeerCellKind(peer: BattleBagSession, gridIndex: number, kind: MapCellKind | null): void {
if (!peer || peer === this || !kind) return;
if (!this.validator.isOwnerCampCell(gridIndex)) {
console.warn('[BattleBagSession] 跳过对端格同步:非己方阵营', this._ownerPlayerId, gridIndex, kind);
return;
}
if (peer.merge.map.hasTile(gridIndex)) {
console.warn('[BattleBagSession] 跳过对端格同步:对端有字', this._ownerPlayerId, gridIndex, kind);
return;
}
if (kind === MapCellKind.Put) {
peer.merge.map.markCellAsPut(gridIndex);
return;
}
if (kind === MapCellKind.Dig) {
peer.merge.map.markCellAsDig(gridIndex);
}
}
private _syncGridVisual(gridIndex: number): void {
const grid = this.validator.getGridByIndex(gridIndex);
if (!grid) return;
if (!this.validator.isOwnerCampCell(gridIndex)) return;
const kind = this.merge.map.getCellKind(gridIndex);
if (!kind) return;
// Dig→Put:默认播 openSpine+fill;铲链/被动产字已 defer 则只切可放置态
if (kind === MapCellKind.Put && grid.isCanBeDig) {
this.validator.applyCellKind(grid, kind);
this._sealDigPutNotWalkable(gridIndex);
if (this._deferOpenFxGridIndex === gridIndex) {
this._deferOpenFxGridIndex = null;
return;
}
// AI dropLogical:无飞铲动画,只切格态并清挖遮罩,避免 dig/FILLED 残留成黑叉
if (this._skipOpenEffect) {
grid.clearDigOpenFx();
return;
}
grid.playOpenEffect();
return;
}
this.validator.applyCellKind(grid, kind);
}
/**
* Dig→Put 后强制不可走。
* 可走性未变时不动寻路(第二次铲再 rebuild 易把怪路径打乱成「猴」柱)。
* 仅 MX 仍可走时强制关闭并延后 refreshEnemyPaths。
* @param gridIndex 目标格
*/
private _sealDigPutNotWalkable(gridIndex: number): void {
const grid = this.validator.getGridByIndex(gridIndex);
const session = this.validator.session;
if (!grid || !session) return;
if (grid.isCanWalk) {
console.log('[BattleBagSession] Dig→Put 后 MapGrid 仍可走,强制关闭', gridIndex);
grid.isCanWalk = false;
grid.updateGrid();
}
const cell = { row: grid.Row, col: grid.Col };
if (!session.grid.inBounds(cell)) return;
if (!session.grid.isWalkable(cell)) {
// Dig→Put 本就不可走:禁止 rebuild/refresh,避免第二次铲叠乱怪路径
return;
}
console.log(`[BattleBagSession] Dig→Put 后 MX 仍可走,强制关闭并延后重寻路 grid=${gridIndex} row=${cell.row} col=${cell.col}`);
session.grid.setWalkable(cell, false);
session.movement.evictEnemiesAtCell(cell.row, cell.col);
this._deferNotifyWalkStateChanged();
}
/** 延后到本帧物理之后再 notifyWalk,避免与 Box2D UpdatePairs 同帧冲突 */
private _deferNotifyWalkStateChanged(): void {
director.once(Director.EVENT_AFTER_UPDATE, this._onDeferredNotifyWalk, this);
}
private _onDeferredNotifyWalk(): void {
if (!this.validator.session) return;
this.validator.notifyWalkStateChanged(true);
}
/** 将逻辑层背包槽同步为表现层可读数据 */
getBagSlots(): readonly (BagSlotRecord | null)[] {
return this.merge.bag.slots;
}
/**
* 地图可放字格是否已满:存在可放字格,且每一格都已有字块。
* 以逻辑层 Put 为准(含刚落下的 type3 空格 / 铲);排除障碍误标 Put。
* 注意:TilePlaced 可能早于 MapGrid.isCanPutText 同步,故不能只看 isCanPutText。
*/
isMapPutSlotsFull(): boolean {
let putCount = 0;
for (const idx of this.merge.map.gridIndices) {
if (this.merge.map.getCellKind(idx) !== MapCellKind.Put) continue;
const grid = this.validator.getGridByIndex(idx);
if (!grid?.node?.isValid || !grid.node.active) continue;
const hasTile = this.merge.map.hasTile(idx);
// 现场可放字,或逻辑已 Put 但视觉尚未 sync(type3 / 铲)
const isPlaceableTextCell =
!!grid.isCanPutText
|| hasTile
|| this.merge.map.isPrepType3(idx)
|| grid.isCanWalk
|| grid.isCanBeDig;
if (!isPlaceableTextCell) continue;
putCount++;
if (!hasTile) return false;
}
return putCount > 0;
}
exportCounters(): {
adRefreshUsed: number;
normalRefreshUsed: number;
heroRefreshAlgorithmUsed: boolean;
firstBattlePending: number;
textPool: PlayerBagTextPoolState;
} {
return {
adRefreshUsed: 0,
normalRefreshUsed: this._normalRefreshUsed,
heroRefreshAlgorithmUsed: this._comboPity.stopped,
firstBattlePending: this._firstHeroPity.pendingPartner,
textPool: this._textPool.exportState(),
};
}
importCounters(counters: {
adRefreshUsed: number;
normalRefreshUsed: number;
heroRefreshAlgorithmUsed: boolean;
firstBattlePending?: number;
textPool?: PlayerBagTextPoolState;
}): void {
this._normalRefreshUsed = counters.normalRefreshUsed;
this._comboPity.setStopped(!!counters.heroRefreshAlgorithmUsed);
this._firstHeroPity.setPendingPartner(counters.firstBattlePending ?? 0);
if (counters.textPool) {
this._textPool.importState(counters.textPool);
}
}
/** 本局普通刷新已用次数(快照恢复后用于引导续接) */
get normalRefreshUsed(): number {
return this._normalRefreshUsed;
}
/** 战斗快照:将己方阵营 merge 逻辑格(Put/Walkable/type3/铲)同步到 MapGrid 表现 */
syncMapCellVisualsFromLogic(): void {
for (const idx of this.merge.map.gridIndices) {
this._syncGridVisual(idx);
}
}
/** Skill.atk_buff type9:可放置格改为可开挖并吞单字 */
skillSwallowDeployTile(gridIndex: number): boolean {
return this.skillSealPutToDig(gridIndex);
}
/**
* 千里眼:可放置格改为可开挖并同步 MapGrid。
* @param gridIndex 地图格
*/
skillSealPutToDig(gridIndex: number): boolean {
const ok = this.merge.skillSealPutToDig(gridIndex);
if (!ok) return false;
this._syncGridVisual(gridIndex);
return true;
}
}
/** 免费格子失败提示(已下线) */
export function freeGridFailMessage(_reason: PlaceRejectReason | null): string {
return '新产品不支持放置格子';
}
/** 铲发放失败提示 */
export function shovelFailMessage(reason: PlaceRejectReason | null): string {
if (reason === PlaceRejectReason.BagFull) {
return '背包已满且没有可开挖格子';
}
if (reason) {
return rejectReasonToMessage(reason) ?? '无法获得铲';
}
return '无法获得铲';
}
/** @deprecated 使用 shovelFailMessage */
export function goldenFingerFailMessage(reason: PlaceRejectReason | null): string {
return shovelFailMessage(reason);
}