import { BattleBagCardKind, bagToolLevelDelta, isBagToolTextType, isElementGlyphType, 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, formatWeapon2Brief, logBagRefreshDebug, logBagRefreshSection, } from '../debug/BagRefreshDebugLog'; import { BAG_SUMMON_SLOT_MAX, getDiscountedBagNormalRefreshCost, scaleBagRefreshCostByFillCount } from './BagPrepConfig'; import { MapPlaceValidator } from './MapPlaceValidator'; import { MapDragGuideHelper } from '../view/MapDragGuideHelper'; import { TextListCellState } from '../view/TextListLandHost'; import { WeaponComposeConfigProvider } from './WeaponComposeConfigProvider'; import { isAttackGlyph } from '../config/BattleHeroTable'; import { FirstBattleHeroPity } from './BagHeroRefreshAlgorithm'; import { PlayerBagTextPool } from './PlayerBagTextPool'; import type { PlayerBagTextPoolState } from './PlayerBagTextPool'; import type { BagRollEntry } from './BagWeaponPool'; import { buildGmWeaponPresetQueue } from '../debug/GmBattlePreset'; import type { ProjectBattleHandle } from '../runtime/BattleRuntime'; import { TableNames } from '../../game/ConfigTableData'; import { RankDataManager } from '../../manager/RankDataManager'; import { unlockHeroTypeName, type AreaUnlockHero } from '../map/legacy/MapAreaUnlock'; import type { DailyShopParsed } from '../../manager/DailyShopTypes'; import { resolveCarriedAttrCardSpawnWan, resolveCarriedDigitDoubleWan, resolveCarriedMultiplierCard, resolveCarriedSummonCostReduceWan, resolveCarriedUnlockCoin, } from '../../manager/battleBuff/BattleBuffPassiveSkillUtil'; import { rollMultiplierCardLevelDelta } from '../../manager/battleBuff/BattleBuffActiveSkillUtil'; import { BattleBuffCarryStore } from '../../manager/battleBuff/BattleBuffCarryStore'; import { BattleBuffSelectService } from '../../manager/battleBuff/BattleBuffSelectService'; /** 倍率卡占位 weapon2Id(非 Text 表 id,靠 battleCardKind 识别) */ const BATTLE_CARD_MULTIPLIER_WEAPON2_ID = 910001; /** 属性卡占位 weapon2Id */ const BATTLE_CARD_ATTR_WEAPON2_ID = 910002; import { getTowerLevelDownHost } from '../combat/TowerLevelDownHost'; import { getTowerRuinHost } from '../combat/TowerRuinHost'; /** 召唤按钮一次最多补的空开启格数量(与 BagPrepConfig.BAG_SUMMON_SLOT_MAX 一致) */ const REFRESH_EMPTY_MAX = BAG_SUMMON_SLOT_MAX; /** 项目层背包会话:桥接 MergeSession 与 MapManager */ export class BattleBagSession { readonly merge: MergeSession; readonly validator: MapPlaceValidator; private readonly _map: MapManager; private readonly _textPool = new PlayerBagTextPool(); /** 新号第一局神将字保底(AI 不使用) */ private readonly _firstHeroPity: FirstBattleHeroPity; /** 刷新扣桃所属 playerId */ private readonly _ownerPlayerId: number; /** 本局已成功普通刷新次数(用于刷新消耗递增) */ private _normalRefreshUsed = 0; /** 最近一次空格刷新写入的槽下标 */ private _lastFilledSlots: number[] = []; /** 属性卡:卡已消耗,等文昌笔播到一半再改字 */ private _pendingAttrRewrite: { target: DropTarget; weapon2Id: number } | null = null; /** PopGM 预设 Weapon1 → Weapon2 顺序队列(每次刷新从队首取,取完走正常逻辑) */ private _gmWeaponPresetQueue: number[] = []; /** 视图层将自行播放 openSpine 的地图格(null=无) */ private _deferOpenFxGridIndex: number | null = null; /** 槽位地块状态查询(视图绑定 ui_textList 后注入);null 视为全部开启 */ landStateOf: ((slotIndex: number) => TextListCellState) | null = null; /** dropLogical(AI)跳过开挖 openSpine,只切格态 */ private _skipOpenEffect = false; /** 最近一次区域解锁打开的格(供对端 merge 对齐) */ private _lastUnlockOpened: number[] = []; /** UnlockNeedElement 动态提示(take 后清空) */ private _unlockFailToast = ''; /** 功能符达上限等动态提示(take 后清空) */ private _toolFailToast = ''; /** * 寻金尺飞币回调(入账后通知表现层)。 * @param gridIndex 解锁锚点格 * @param coin 本次随机金币 * @param ownerPlayerId 归属玩家 */ onUnlockCoinFly: ((gridIndex: number, coin: number, ownerPlayerId: number) => void) | null = null; /** * 地图区域解锁达到 Const27 次数时回调(玩家弹窗 / AI 自动选)。 * @param ownerPlayerId 归属玩家 * @param unlockCount 本阵营已完成解锁次数(含本次) */ onBattleBuffSelect: ((ownerPlayerId: number, unlockCount: number) => void) | null = null; 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(); const rankId = opts?.rankId != null ? Math.floor(Number(opts.rankId) || 0) : RankDataManager.getPlayerRankId(); this._bindUnlockLevel(chapterId, rankId); BattleBuffSelectService.ensurePlayer(this._ownerPlayerId); this._textPool.beginBattle(rankId, {}); 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); this.merge.beginPrepRound(); // 开局字块由视图绑定地块状态后调用 autoFillOpenSlots:只填 tolockland } /** 刷新扣桃所属 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; } /** * 本次召唤消耗:满额按 Const(含省金),再按当前空开启格数 / 5 折算。 * 无空格时为 0;按钮预览与扣费共用。 */ get normalRefreshCost(): number { const full = this._fullSummonCost(); if (!this.landStateOf) return full; const fill = Math.min(REFRESH_EMPTY_MAX, this._emptyOpenSlots().length); return scaleBagRefreshCostByFillCount(full, fill); } /** 满 5 格召唤档位消耗(Const + 省金,未按空位折算) */ private _fullSummonCost(): number { const reduceWan = resolveCarriedSummonCostReduceWan(this._currentCarryPassives()).reduceWan; return getDiscountedBagNormalRefreshCost(this._normalRefreshUsed, reduceWan); } /** 字池是否还能再填满一次背包 */ 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); } if (firstRefresh) this._textPool.shuffleEntries(slots); } // 按槽位地块状态修正:未解锁槽留空(退还),待解锁槽换成 type1-5 抽取 for (let i = 0; i < slots.length; i++) { const rec = slots[i]; if (!rec) continue; const state = this.landStateOf?.(i) ?? TextListCellState.Open; if (state === TextListCellState.Locked || state === TextListCellState.Blank) { this._textPool.tryRefundWeapon2Id(rec.weapon2Id); delete slots[i]; continue; } if (state === TextListCellState.OpenNoDrag) { const roll = this._textPool.rollOneForLand(true); if (roll != null && roll.weapon2Id !== rec.weapon2Id) { this._textPool.tryRefundWeapon2Id(rec.weapon2Id); slots[i] = { weapon2Id: roll.weapon2Id, level: roll.level }; } } } this._trySpawnBattleCardsIntoSlots(slots); logBagRefreshDebug('最终背包', slots.map((e, i) => `${i}:${e ? formatWeapon2Brief(e.weapon2Id, config) : '空'}`).join(', ')); logBagRefreshSection('背包普通刷新结束'); this.merge.refreshBag(slots); } /** * 开局只给 tolockland(待解锁)填随机字。 * normalland 开启但留空;未解锁不填。不占刷新次数;玩家/AI 各自 bag 独立调用。 */ autoFillOpenSlots(): void { if (!this.landStateOf) { console.log('[BattleBagSession] 未绑定地块状态,跳过开局填字'); return; } const slotCount = this.merge.bag.slotCount; const filled: string[] = []; for (let i = 0; i < slotCount; i++) { if (this.merge.bag.getSlot(i)) continue; if (this.landStateOf(i) !== TextListCellState.OpenNoDrag) continue; const roll = this._textPool.rollOneForLand(true); if (roll == null) continue; this.merge.bag.setSlot(i, { weapon2Id: roll.weapon2Id, level: roll.level }); filled.push(`${i}:${roll.weapon2Id}(Lv${roll.level})`); } console.log(`[BattleBagSession] 开局填充槽位 ${filled.length ? filled.join(', ') : '无'} slotCount=${slotCount} 池就绪=${this._textPool.ready}`); } /** * 新的待开启格补一个五行随机字(不广播整包刷新)。 * 显示等级 = 抽取等级 + 本次解锁加值,不超过该五行上限。 * @param slotIndex 待开启槽下标 * @param levelAdd 本次解锁的等级加值(Const 39);0 不加 * @returns 随机出来的显示等级;未写入返回 null */ fillGateUnlockSlot(slotIndex: number, levelAdd: number): number | null { if (this.landStateOf?.(slotIndex) !== TextListCellState.OpenNoDrag) { console.log('[BattleBagSession] 非待开启格不出字', slotIndex); return null; } const roll = this._textPool.rollOneForLand(true); if (roll == null) { console.log('[BattleBagSession] 待开启格字池抽空', slotIndex); return null; } const level = this._gateLevel(roll.weapon2Id, roll.level, levelAdd); this.merge.bag.setSlot(slotIndex, { weapon2Id: roll.weapon2Id, level }); return Math.floor(roll.level); } /** * 给本次解锁新生的待开启格出字。 * @param slotIndices 新生槽 * @param levelAdd 本次解锁的等级加值 * @returns 各格随机等级,形如「槽:等级」 */ fillGateUnlockSlots(slotIndices: readonly number[], levelAdd: number): string[] { const rolls: string[] = []; for (let i = 0; i < slotIndices.length; i++) { const slot = slotIndices[i]; const rolled = this.fillGateUnlockSlot(slot, levelAdd); rolls.push(rolled == null ? `${slot}:无` : `${slot}:${rolled}`); } return rolls; } /** * 待开启出字等级:抽取等级加上解锁加值,不超过该五行上限。 * @param weapon2Id Text.id * @param level 抽取显示等级 * @param levelAdd Const 39 加值 */ private _gateLevel(weapon2Id: number, level: number, levelAdd: number): number { const base = Math.floor(level); const add = Math.floor(Number(levelAdd)); if (!Number.isFinite(add) || add < 0) { console.log('[BattleBagSession] 解锁等级加值非法', weapon2Id, levelAdd); return base; } if (add === 0) return base; const w2Type = Math.floor(WeaponComposeConfigProvider.instance.getWeapon2Type(weapon2Id)); if (w2Type < 1 || w2Type > 5) { console.log('[BattleBagSession] 解锁加值目标非五行', weapon2Id, w2Type); return base; } const maxLv = WeaponComposeConfigProvider.instance.getElementMaxLevel(w2Type); const sum = base + add; if (sum > maxLv) { console.log('[BattleBagSession] 解锁加值超过五行上限', weapon2Id, base, add, maxLv); return maxLv; } return sum; } /** 两个字是否五行类型相同且显示等级相同 */ private _sameElementLevel(a: { weapon2Id: number; level?: number }, b: { weapon2Id: number; level?: number }): boolean { const provider = WeaponComposeConfigProvider.instance; provider.load(); const typeA = Math.floor(provider.getWeapon2Type(a.weapon2Id)); const typeB = Math.floor(provider.getWeapon2Type(b.weapon2Id)); if (typeA < 1 || typeA > 5 || typeA !== typeB) return false; return Math.floor(a.level ?? 1) === Math.floor(b.level ?? 1); } /** 本批是否已有可攻击单字 */ private _slotsHaveAttacker(slots: readonly { weapon2Id: number }[]): boolean { for (let i = 0; i < slots.length; i++) { if (isAttackGlyph(slots[i].weapon2Id)) return true; } return false; } /** 预览 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._fullSummonCost(); 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'; } } /** 最近一次空格刷新写入的槽 */ lastFilledSlotIndices(): readonly number[] { return this._lastFilledSlots; } /** * 倍增卡:对本次刷新出的数字逐格掷骰,命中则 level×2(受五行上限钳制)。 * 应在入场飞入全部结束后调用;AI 无入场动画可立即调用。 * @param slotIndices 候选槽;缺省用最近一次空格刷新写入槽 * @returns 实际升级的槽下标 */ applyDigitDoubleAfterRefresh(slotIndices?: readonly number[]): number[] { const adj = resolveCarriedDigitDoubleWan(this._currentCarryPassives()); if (!adj.active || adj.doubleWan <= 0) return []; const indices = slotIndices ?? this._lastFilledSlots; if (!indices.length) return []; const config = WeaponComposeConfigProvider.instance; const upgraded: number[] = []; for (let i = 0; i < indices.length; i++) { const slotIndex = indices[i]; const rec = this.merge.bag.getSlot(slotIndex); if (!rec || rec.battleCardKind || !(rec.weapon2Id > 0)) continue; if (Math.random() * 10000 >= adj.doubleWan) continue; const lv = Math.max(1, Math.floor(Number(rec.level) || 1)); const w2Type = Math.floor(config.getWeapon2Type(rec.weapon2Id)); const maxLv = w2Type >= 1 && w2Type <= 5 ? config.getElementMaxLevel(w2Type) : lv * 2; const nextLv = Math.min(lv * 2, Math.max(1, maxLv)); if (nextLv <= lv) continue; const err = this.merge.applyBattleCardToBagSlot(slotIndex, rec.weapon2Id, nextLv); if (err != null) { console.log('[BattleBagSession] 倍增卡升级失败', slotIndex, rejectReasonToMessage(err)); continue; } upgraded.push(slotIndex); } return upgraded; } /** * 召唤:只给当前空的开启格补随机字,最多 5 个。已有字的格子不动。 * 没有空开启格时不扣桃。 */ tryFillEmptyOpenSlots(battle: ProjectBattleHandle): 'ok' | 'not_enough' | 'failed' | 'pool_empty' | 'no_slot' { this._lastFilledSlots = []; if (!this.landStateOf) { console.log('[BattleBagSession] 未绑定地块状态,不能按空格刷新'); return 'failed'; } const empty = this._emptyOpenSlots(); if (empty.length === 0) return 'no_slot'; const count = Math.min(REFRESH_EMPTY_MAX, empty.length); if (!this._textPool.canRollSlots(count)) return 'pool_empty'; const slot = battle.session.players[this._ownerPlayerId] ?? (this._ownerPlayerId === 0 ? battle.localSlot : undefined); if (!slot) { console.log('[BattleBagSession] tryFillEmptyOpenSlots: player slot missing', this._ownerPlayerId); return 'failed'; } const cost = scaleBagRefreshCostByFillCount(this._fullSummonCost(), count); if (slot.gold < cost) return 'not_enough'; if (!slot.spendGold(cost)) return 'not_enough'; if (this._ownerPlayerId === 0) { battle.CurentCoinNum = slot.gold; } try { const rolled = this._textPool.rollSlots(count, false, false); if (!rolled || rolled.length < count) { throw new Error(`[BattleBagSession] 空格抽取不足 need=${count}`); } const filled: number[] = []; for (let i = 0; i < count; i++) { const index = empty[i]; this.merge.bag.setSlot(index, { weapon2Id: rolled[i].weapon2Id, level: rolled[i].level }); filled.push(index); } this._trySpawnBattleCardsIntoFilledSlots(filled); this._lastFilledSlots = filled; this._normalRefreshUsed++; logBagRefreshSection(`空格刷新 ${filled.join(',')} 共${count}`); return 'ok'; } catch (e) { slot.addGold(cost); if (this._ownerPlayerId === 0) { battle.CurentCoinNum = slot.gold; } console.error('[BattleBagSession] tryFillEmptyOpenSlots failed', String(e)); return 'failed'; } } /** 当前没有字的开启格,按下标升序 */ private _emptyOpenSlots(): number[] { const out: number[] = []; if (!this.landStateOf) return out; const slotCount = this.merge.bag.slotCount; for (let i = 0; i < slotCount; i++) { if (this.landStateOf(i) !== TextListCellState.Open) continue; if (this.merge.bag.getSlot(i)) continue; out.push(i); } return out; } /** * AI 逻辑落子:只改无头 Merge,不走底栏拖拽表现 / GEvent。 * 仍须同步 MapGrid 格语义(铲 Dig→Put 等),否则引导/落子探测与逻辑层脱节。 * @param source 拖拽源 * @param target 目标 */ dropLogical(source: DragSource, target: DropTarget): PlaceRejectReason | null { const toolHit = this._tryApplyBagTool(source, target); if (toolHit !== 'skip') { if (!toolHit) { this.flushPendingAttrRewrite(); this._syncChangedCellVisuals(source, target); GEvent.Ins.emit(GEvent.UpdateBestPlace); } return toolHit; } const cardHit = this._tryApplyBattleCard(source, target); if (cardHit !== 'skip') { if (!cardHit) { this.flushPendingAttrRewrite(); this._syncChangedCellVisuals(source, target); GEvent.Ins.emit(GEvent.UpdateBestPlace); } return cardHit; } if (target.kind === 'map' && !this.validator.isAllowedCampForPlace(target.gridIndex)) { return PlaceRejectReason.WrongCampArea; } if (target.kind === 'map') { const unlockHit = this._dragUnlockArea(source, target.gridIndex); if (unlockHit !== 'skip') return unlockHit; } if (target.kind === 'bag') { const land = this.landStateOf?.(target.slotIndex); if (land === TextListCellState.Locked || land === TextListCellState.Blank) { return PlaceRejectReason.WrongCellKind; } if (land === TextListCellState.OpenNoDrag) { const dst = this.merge.bag.getSlot(target.slotIndex); const src = source.kind === 'bag' ? this.merge.bag.getSlot(source.slotIndex) : this.merge.map.getTile(source.gridIndex); if (!dst || !src || !this._sameElementLevel(src, dst)) { return PlaceRejectReason.InvalidMerge; } } } 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; } /** * 蟠桃树被动落点:优先空 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.landStateOf?.(i) === TextListCellState.Blank) 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(); } /** 是否可接收偷取字块(地图空 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; } /** 视图层将自行播放被动产字直落时标记,跳过 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[1]): void { this.merge.revertType3Pickup(gridIndex, tile); this._onWalkabilityCellChanged(gridIndex, true); } /** * 地图字拖到 ui_delete 时删掉,不退回字池。 * @param gridIndex 来源地图格 */ discardMapGlyph(gridIndex: number): boolean { const before = this.merge.map.getCellKind(gridIndex); const ok = this.merge.discardDraggedMapGlyph(gridIndex); if (!ok) return false; const next = this.merge.map.getCellKind(gridIndex); if (this._walkabilityChanged(before, next)) { const becameBlocked = before === MapCellKind.Walkable && next !== MapCellKind.Walkable; this._onWalkabilityCellChanged(gridIndex, becameBlocked); } else { this._syncGridVisual(gridIndex); } GEvent.Ins.emit(GEvent.UpdateBestPlace); return true; } /** * 背包字拖到 ui_delete 时删掉,不退回字池。 * @param slotIndex 来源槽 */ discardBagSlot(slotIndex: number): boolean { const rec = this.merge.bag.getSlot(slotIndex); if (!rec) { console.log('[BattleBagSession] 空槽无法删除', slotIndex); return false; } this.merge.bag.clearSlot(slotIndex); GEvent.Ins.emit(GEvent.UpdateBestPlace); return true; } drop(source: DragSource, target: DropTarget): PlaceRejectReason | null { const toolHit = this._tryApplyBagTool(source, target); if (toolHit !== 'skip') { if (toolHit) { this.merge.reject(toolHit); return toolHit; } this._syncChangedCellVisuals(source, target); GEvent.Ins.emit(GEvent.UpdateBestPlace); return null; } const cardHit = this._tryApplyBattleCard(source, target); if (cardHit !== 'skip') { if (cardHit) { this.merge.reject(cardHit); return cardHit; } this._syncChangedCellVisuals(source, target); GEvent.Ins.emit(GEvent.UpdateBestPlace); return 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; } if (target.kind === 'bag') { const land = this.landStateOf?.(target.slotIndex); if (land === TextListCellState.Locked || land === TextListCellState.Blank) { this.merge.reject(PlaceRejectReason.WrongCellKind); return PlaceRejectReason.WrongCellKind; } if (land === TextListCellState.OpenNoDrag) { const dst = this.merge.bag.getSlot(target.slotIndex); const src = source.kind === 'bag' ? this.merge.bag.getSlot(source.slotIndex) : this.merge.map.getTile(source.gridIndex); if (!dst || !src || !this._sameElementLevel(src, dst)) { this.merge.reject(PlaceRejectReason.InvalidMerge); return PlaceRejectReason.InvalidMerge; } } } 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; } /** * 用关卡 unlockland 和段位 levelvalue 绑定当前解锁条件。 * @param levelId 关卡表 id * @param rankId 段位表 id */ private _bindUnlockLevel(levelId: number, rankId: number): void { const rank = RankDataManager.getRankConfig(rankId); const levelValue = Number(rank?.levelvalue); if (!rank || !Number.isFinite(levelValue)) { console.warn('[BattleBagSession] 段位缺少 levelvalue', rankId); return; } // playerId 0→Camp1,1→Camp2;解锁条件加值按阵营隔离 this._map.areaUnlock.setLevel(levelId, levelValue, this._ownerPlayerId + 1); } /** * 取出并清空最近一次区域解锁打开的格索引。 */ consumeUnlockOpened(): number[] { const out = this._lastUnlockOpened; this._lastUnlockOpened = []; return out; } /** * 拖字是否满足当前解锁区域:Text.type 对齐 unlockland 类型(0 为任意),显示等级达到需求。 * @param gridIndex 落点 * @param weapon2Id 文字 id * @param level 字块显示等级 */ canUnlockArea(gridIndex: number, weapon2Id: number, level?: number): boolean { const grid = this.validator.getGridByIndex(gridIndex); if (!grid?.isAreaUnlock) return false; const lv = Math.floor(Number(level)); if (!(lv > 0)) return false; WeaponComposeConfigProvider.instance.load(); return this._map.areaUnlock.canDrag(grid, this._heroOfGlyph({ weapon2Id, level: lv })); } /** * 落点所属阵营当前解锁区需求 herotype;0=任意或非待解锁。 * @param gridIndex 落点 */ currentUnlockNeedHeroType(gridIndex: number): number { const grid = this.validator.getGridByIndex(gridIndex); if (!grid?.waitingUnlock) return 0; return this._map.areaUnlock.currentNeedHeroType(grid.CampAreaID | 0); } /** * 拒绝原因文案;UnlockNeedElement / 功能符上限用动态提示。 * @param reason 拒绝原因 */ formatRejectMessage(reason: PlaceRejectReason): string { if (reason === PlaceRejectReason.UnlockNeedElement) { const tip = this._unlockFailToast; this._unlockFailToast = ''; return tip; } if (this._toolFailToast) { const tip = this._toolFailToast; this._toolFailToast = ''; return tip; } return rejectReasonToMessage(reason); } /** * 五行字拖到当前解锁区域:类型/显示等级符合则整区可放置并播 openSpine,随后字落在当前格。 * @param source 拖拽源 * @param gridIndex 落点 */ private _dragUnlockArea(source: DragSource, gridIndex: number): PlaceRejectReason | 'skip' { const grid = this.validator.getGridByIndex(gridIndex); if (!grid?.isAreaUnlock) return 'skip'; const piece = this._sourcePiece(source); if (!(piece.weapon2Id > 0)) return 'skip'; WeaponComposeConfigProvider.instance.load(); const w2Type = Math.floor(WeaponComposeConfigProvider.instance.getWeapon2Type(piece.weapon2Id)); if (w2Type < 1 || w2Type > 5) return 'skip'; const hero = this._heroOfGlyph(piece); const hit = this._map.areaUnlock.tryDrag(grid, hero); if (hit === 'skip') return 'skip'; if (hit === 'blocked') { const camp = grid.CampAreaID | 0; const needType = this._map.areaUnlock.currentNeedHeroType(camp); const name = unlockHeroTypeName(needType); if (grid.waitingUnlock && needType > 0 && name && (!hero || hero.heroType !== needType)) { this._unlockFailToast = `需要用${name}属性数字解锁`; return PlaceRejectReason.UnlockNeedElement; } return PlaceRejectReason.WrongCellKind; } const camp = grid.CampAreaID | 0; const opened = this._map.areaUnlock.gridsOf(grid.unlockAreaId, camp); this._lastUnlockOpened = []; 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._lastUnlockOpened.push(cell.Index); if (!this._skipOpenEffect) cell.playOpenSpineSmoke(); else cell.clearDigOpenFx(); } this._map.areaUnlock.applyVisual(); this._grantUnlockCoinReward(grid.Index); const unlockCount = this._map.areaUnlock.completedCount(camp); if (BattleBuffSelectService.isTriggerUnlockCount(unlockCount)) { this.onBattleBuffSelect?.(this._ownerPlayerId, unlockCount); } return 'skip'; } /** * 寻金尺:解锁地块成功后在 [min,max] 含端点随机金币并通知飞币。 * @param anchorGridIndex 解锁锚点格(飞币起点) */ private _grantUnlockCoinReward(anchorGridIndex: number): void { const cfg = resolveCarriedUnlockCoin(this._currentCarryPassives()); if (!cfg) return; const min = Math.max(0, Math.floor(cfg.minCoin)); const max = Math.max(0, Math.floor(cfg.maxCoin)); const lo = Math.min(min, max); const hi = Math.max(min, max); const coin = lo >= hi ? lo : lo + Math.floor(Math.random() * (hi - lo + 1)); if (coin <= 0) { console.log('[BattleBagSession] 寻金尺金币无效', lo, hi); return; } const battle = gg.game.CurentBattle; if (battle) { battle.addPlayerGold(this._ownerPlayerId, coin); } else { const slot = this.validator.session?.players[this._ownerPlayerId]; if (!slot) { console.log('[BattleBagSession] 寻金尺无玩家槽', this._ownerPlayerId); return; } slot.addGold(coin); } this.onUnlockCoinFly?.(anchorGridIndex, coin, this._ownerPlayerId); } /** 当前携带被动(本局 CarryStore) */ private _currentCarryPassives(): readonly DailyShopParsed[] { return BattleBuffCarryStore.getPassives(this._ownerPlayerId); } /** * 倍率卡/属性卡:整次刷新各掷一次,命中则放入开启格(优先空槽,否则替换一格数字)。 * @param slots 稀疏槽数组 */ private _trySpawnBattleCardsIntoSlots(slots: ({ weapon2Id: number; level?: number; battleCardKind?: BattleBagCardKind } | undefined)[]): void { const passives = this._currentCarryPassives(); const mult = resolveCarriedMultiplierCard(passives); if (mult && mult.spawnWan > 0 && Math.random() * 10000 < mult.spawnWan) { this._placeBattleCardInSlots(slots, BattleBagCardKind.Multiplier); } const attr = resolveCarriedAttrCardSpawnWan(passives); if (attr.active && attr.spawnWan > 0 && Math.random() * 10000 < attr.spawnWan) { this._placeBattleCardInSlots(slots, BattleBagCardKind.Attr); } } /** * 召唤补空格:整次召唤各掷一次,命中则产卡(可同出,不互斥)。 * @param filledIndices 本次写入的开启格下标(产到额外空格时会追加) */ private _trySpawnBattleCardsIntoFilledSlots(filledIndices: number[]): void { if (filledIndices.length <= 0) return; const passives = this._currentCarryPassives(); const mult = resolveCarriedMultiplierCard(passives); if (mult && mult.spawnWan > 0 && Math.random() * 10000 < mult.spawnWan) { this._replaceFilledSlotWithCard(filledIndices, BattleBagCardKind.Multiplier); } const attr = resolveCarriedAttrCardSpawnWan(passives); if (attr.active && attr.spawnWan > 0 && Math.random() * 10000 < attr.spawnWan) { this._replaceFilledSlotWithCard(filledIndices, BattleBagCardKind.Attr); } } /** * 将产卡写入背包刷新结果:优先空开启格,否则随机替换一格可拖数字并退还字池。 * @param slots 槽数组 * @param kind 卡种类 */ private _placeBattleCardInSlots(slots: ({ weapon2Id: number; level?: number; battleCardKind?: BattleBagCardKind } | undefined)[], kind: BattleBagCardKind): void { const cardWeapon2Id = kind === BattleBagCardKind.Multiplier ? BATTLE_CARD_MULTIPLIER_WEAPON2_ID : BATTLE_CARD_ATTR_WEAPON2_ID; for (let i = 0; i < slots.length; i++) { if (slots[i]) continue; const state = this.landStateOf?.(i) ?? TextListCellState.Open; if (state !== TextListCellState.Open) continue; slots[i] = { weapon2Id: cardWeapon2Id, battleCardKind: kind }; return; } const candidates: number[] = []; for (let i = 0; i < slots.length; i++) { const rec = slots[i]; if (!rec || rec.battleCardKind) continue; const state = this.landStateOf?.(i) ?? TextListCellState.Open; if (state !== TextListCellState.Open) continue; candidates.push(i); } if (candidates.length <= 0) { console.log('[BattleBagSession] 产卡无可用开启格 kind=', kind); return; } const pick = candidates[Math.floor(Math.random() * candidates.length)]; const old = slots[pick]; if (old && old.weapon2Id > 0) { this._textPool.tryRefundWeapon2Id(old.weapon2Id); } slots[pick] = { weapon2Id: cardWeapon2Id, battleCardKind: kind }; } /** * 召唤产卡:优先替换本次新补数字;否则空开启格;再否则其它可拖数字(倍率卡/属性卡可同出)。 * @param filledIndices 本次新补下标(写入额外格时追加,供飞入) * @param kind 卡种类 */ private _replaceFilledSlotWithCard(filledIndices: number[], kind: BattleBagCardKind): void { const cardWeapon2Id = kind === BattleBagCardKind.Multiplier ? BATTLE_CARD_MULTIPLIER_WEAPON2_ID : BATTLE_CARD_ATTR_WEAPON2_ID; const digitCandidates: number[] = []; for (let i = 0; i < filledIndices.length; i++) { const index = filledIndices[i]; const rec = this.merge.bag.getSlot(index); if (!rec || rec.battleCardKind) continue; digitCandidates.push(index); } if (digitCandidates.length > 0) { this._writeBattleCardToSlot( digitCandidates[Math.floor(Math.random() * digitCandidates.length)], cardWeapon2Id, kind, filledIndices, ); return; } const empty = this._emptyOpenSlots(); if (empty.length > 0) { this._writeBattleCardToSlot( empty[Math.floor(Math.random() * empty.length)], cardWeapon2Id, kind, filledIndices, ); return; } const otherDigits = this._openDigitSlotIndices(); if (otherDigits.length > 0) { this._writeBattleCardToSlot( otherDigits[Math.floor(Math.random() * otherDigits.length)], cardWeapon2Id, kind, filledIndices, ); return; } console.log('[BattleBagSession] 召唤产卡无可用格 kind=', kind); } /** 可拖开启格上的普通数字槽(不含产卡) */ private _openDigitSlotIndices(): number[] { const out: number[] = []; if (!this.landStateOf) return out; const slotCount = this.merge.bag.slotCount; for (let i = 0; i < slotCount; i++) { if (this.landStateOf(i) !== TextListCellState.Open) continue; const rec = this.merge.bag.getSlot(i); if (!rec || rec.battleCardKind || !(rec.weapon2Id > 0)) continue; out.push(i); } return out; } /** * 写入产卡到指定槽;若原为数字则退还字池。 * @param slotIndex 槽 * @param cardWeapon2Id 占位 id * @param kind 卡种类 * @param filledIndices 飞入列表;槽不在其中时追加 */ private _writeBattleCardToSlot( slotIndex: number, cardWeapon2Id: number, kind: BattleBagCardKind, filledIndices?: number[], ): void { const old = this.merge.bag.getSlot(slotIndex); if (old && !old.battleCardKind && old.weapon2Id > 0) { this._textPool.tryRefundWeapon2Id(old.weapon2Id); } this.merge.bag.setSlot(slotIndex, { weapon2Id: cardWeapon2Id, battleCardKind: kind }); if (filledIndices) { let found = false; for (let i = 0; i < filledIndices.length; i++) { if (filledIndices[i] === slotIndex) { found = true; break; } } if (!found) filledIndices.push(slotIndex); } logBagRefreshDebug('召唤产卡', `kind=${kind} slot=${slotIndex}`); } /** * Text 功能符(type 10–13)拖到五行数字上生效。 * @returns skip=非功能符;null=成功;否则拒绝原因(不再走普通落子) */ private _tryApplyBagTool(source: DragSource, target: DropTarget): PlaceRejectReason | null | 'skip' { if (source.kind !== 'bag') return 'skip'; const src = this.merge.bag.getSlot(source.slotIndex); if (!src || src.battleCardKind) return 'skip'; WeaponComposeConfigProvider.instance.load(); const toolType = Math.floor(WeaponComposeConfigProvider.instance.getWeapon2Type(src.weapon2Id)); if (!isBagToolTextType(toolType)) return 'skip'; this._toolFailToast = ''; if (target.kind === 'bag') { const dst = this.merge.bag.getSlot(target.slotIndex); if (!dst) return 'skip'; return this._applyBagToolToBag(source.slotIndex, target.slotIndex, toolType); } if (!this.merge.map.getTile(target.gridIndex)) { return PlaceRejectReason.InvalidMerge; } return this._applyBagToolToMap(source.slotIndex, target.gridIndex, toolType); } /** * 功能符拖到背包数字(衰弱符不可;非五行目标 skip 走槽位交换)。 * @param fromSlot 符槽 * @param toSlot 目标槽 * @param toolType Text.type */ private _applyBagToolToBag(fromSlot: number, toSlot: number, toolType: number): PlaceRejectReason | null | 'skip' { if (toolType === 13) return PlaceRejectReason.InvalidMerge; const land = this.landStateOf?.(toSlot) ?? TextListCellState.Open; if (land !== TextListCellState.Open) return PlaceRejectReason.InvalidMerge; const dst = this.merge.bag.getSlot(toSlot); if (!dst || dst.battleCardKind) return PlaceRejectReason.InvalidMerge; const dstType = Math.floor(WeaponComposeConfigProvider.instance.getWeapon2Type(dst.weapon2Id)); if (!isElementGlyphType(dstType)) return 'skip'; const level = Math.max(1, Math.floor(Number(dst.level) || 1)); const resolved = this._resolveBagToolResult(Number(dst.weapon2Id) || 0, level, toolType); if (resolved.reason) return resolved.reason; if (toolType === 12) { this._pendingAttrRewrite = { target: { kind: 'bag', slotIndex: toSlot }, weapon2Id: resolved.weapon2Id }; this.merge.bag.clearSlot(fromSlot); return null; } const err = this.merge.applyBattleCardToBagSlot(toSlot, resolved.weapon2Id, resolved.level); if (err) return err; this.merge.bag.clearSlot(fromSlot); return null; } /** * 功能符拖到地图数字(衰弱符仅对方阵营)。 * @param fromSlot 符槽 * @param gridIndex 目标格 * @param toolType Text.type */ private _applyBagToolToMap(fromSlot: number, gridIndex: number, toolType: number): PlaceRejectReason | null { const tile = this.merge.map.getTile(gridIndex); if (!tile || tile.battleCardKind) return PlaceRejectReason.InvalidMerge; const ownCamp = this.validator.isAllowedCampForPlace(gridIndex); if (toolType === 13) { if (ownCamp) return PlaceRejectReason.InvalidMerge; } else if (!ownCamp) { return PlaceRejectReason.WrongCampArea; } const level = Math.max(1, Math.floor(Number(tile.level) || 1)); const resolved = this._resolveBagToolResult(Number(tile.weapon2Id) || 0, level, toolType); if (resolved.reason) return resolved.reason; if (toolType === 12) { this._pendingAttrRewrite = { target: { kind: 'map', gridIndex }, weapon2Id: resolved.weapon2Id }; this.merge.bag.clearSlot(fromSlot); return null; } const err = this.merge.applyBattleCardToMapTile(gridIndex, resolved.weapon2Id, resolved.level); if (err) return err; this.merge.bag.clearSlot(fromSlot); return null; } /** * 计算功能符作用结果。 * @param weapon2Id 目标字 * @param level 显示等级 * @param toolType Text.type */ private _resolveBagToolResult(weapon2Id: number, level: number, toolType: number): { weapon2Id: number; level: number; reason: PlaceRejectReason | null } { const w2Type = Math.floor(WeaponComposeConfigProvider.instance.getWeapon2Type(weapon2Id)); const lv = Math.max(1, Math.floor(Number(level) || 1)); if (!isElementGlyphType(w2Type)) { return { weapon2Id, level: lv, reason: PlaceRejectReason.InvalidMerge }; } if (toolType === 12) { const rolled = this._textPool.pickAttrSwap(weapon2Id, lv); if (!rolled) return { weapon2Id, level: lv, reason: PlaceRejectReason.InvalidMerge }; return { weapon2Id: rolled.weapon2Id, level: rolled.level, reason: null }; } const delta = bagToolLevelDelta(toolType); if (delta === 0) return { weapon2Id, level: lv, reason: PlaceRejectReason.InvalidMerge }; if (delta < 0) { if (lv <= 1) { this._toolFailToast = '数字已是最小值,无法使用'; return { weapon2Id, level: lv, reason: PlaceRejectReason.MinLevel }; } return { weapon2Id, level: lv - 1, reason: null }; } const maxLv = WeaponComposeConfigProvider.instance.getElementMaxLevel(w2Type); const newLv = Math.min(maxLv, lv + delta); if (newLv <= lv) { this._toolFailToast = '数字已达上限,无法使用!'; return { weapon2Id, level: lv, reason: PlaceRejectReason.MaxLevel }; } return { weapon2Id, level: newLv, reason: null }; } /** * 倍率卡/属性卡拖到五行数字上生效。 * @returns skip=非卡路径;null=已处理成功;否则拒绝原因 */ private _tryApplyBattleCard(source: DragSource, target: DropTarget): PlaceRejectReason | null | 'skip' { if (source.kind !== 'bag') return 'skip'; const src = this.merge.bag.getSlot(source.slotIndex); const kind = Math.floor(Number(src?.battleCardKind) || 0); if (kind !== BattleBagCardKind.Multiplier && kind !== BattleBagCardKind.Attr) return 'skip'; if (target.kind === 'bag') { return this._applyBattleCardToBag(source.slotIndex, target.slotIndex, kind); } return this._applyBattleCardToMap(source.slotIndex, target.gridIndex, kind); } /** * 取出待播属性卡改字(表现层消费);无则 null。 */ consumePendingAttrRewrite(): { target: DropTarget; weapon2Id: number } | null { const pending = this._pendingAttrRewrite; this._pendingAttrRewrite = null; return pending; } /** * 无表现时立刻提交属性卡改字(AI / 表现起播失败兜底)。 */ flushPendingAttrRewrite(): void { const pending = this.consumePendingAttrRewrite(); if (!pending) return; this.commitAttrRewrite(pending.target, pending.weapon2Id); } /** * 提交属性卡改字(文昌笔半程回调)。 * @param target 目标 * @param weapon2Id 新字 */ commitAttrRewrite(target: DropTarget, weapon2Id: number): PlaceRejectReason | null { if (target.kind === 'bag') { return this.merge.applyBattleBuffRewriteGlyphBag(target.slotIndex, weapon2Id); } return this.merge.applyBattleBuffRewriteGlyphMap(target.gridIndex, weapon2Id); } /** * 卡拖到背包数字(仅可拖开启格;蛛网/未解锁不可用)。 * @param fromSlot 卡槽 * @param toSlot 目标槽 * @param kind 卡种类 */ private _applyBattleCardToBag(fromSlot: number, toSlot: number, kind: BattleBagCardKind): PlaceRejectReason | null | 'skip' { const land = this.landStateOf?.(toSlot) ?? TextListCellState.Open; if (land !== TextListCellState.Open) { return PlaceRejectReason.InvalidMerge; } const dst = this.merge.bag.getSlot(toSlot); if (!dst) return 'skip'; if (dst.battleCardKind) return PlaceRejectReason.InvalidMerge; const level = Math.max(1, Math.floor(Number(dst.level) || 1)); const resolved = this._resolveBattleCardResult(Number(dst.weapon2Id) || 0, level, kind); if (resolved.reason) return resolved.reason; if (kind === BattleBagCardKind.Attr) { this._pendingAttrRewrite = { target: { kind: 'bag', slotIndex: toSlot }, weapon2Id: resolved.weapon2Id }; this.merge.bag.clearSlot(fromSlot); return null; } const err = this.merge.applyBattleCardToBagSlot(toSlot, resolved.weapon2Id, resolved.level); if (err) return err; this.merge.bag.clearSlot(fromSlot); return null; } /** * 卡拖到地图数字。 * @param fromSlot 卡槽 * @param gridIndex 目标格 * @param kind 卡种类 */ private _applyBattleCardToMap(fromSlot: number, gridIndex: number, kind: BattleBagCardKind): PlaceRejectReason | null | 'skip' { const tile = this.merge.map.getTile(gridIndex); if (!tile) return PlaceRejectReason.InvalidMerge; if (tile.battleCardKind) return PlaceRejectReason.InvalidMerge; const level = Math.max(1, Math.floor(Number(tile.level) || 1)); const resolved = this._resolveBattleCardResult(Number(tile.weapon2Id) || 0, level, kind); if (resolved.reason) return resolved.reason; if (kind === BattleBagCardKind.Attr) { this._pendingAttrRewrite = { target: { kind: 'map', gridIndex }, weapon2Id: resolved.weapon2Id }; this.merge.bag.clearSlot(fromSlot); return null; } const err = this.merge.applyBattleCardToMapTile(gridIndex, resolved.weapon2Id, resolved.level); if (err) return err; this.merge.bag.clearSlot(fromSlot); return null; } /** * 计算卡作用后的字块结果。 * @param weapon2Id 目标字 * @param level 显示等级(已归一 ≥1) * @param kind 卡种类 */ private _resolveBattleCardResult(weapon2Id: number, level: number, kind: BattleBagCardKind): { weapon2Id: number; level: number; reason: PlaceRejectReason | null } { const w2Type = Math.floor(WeaponComposeConfigProvider.instance.getWeapon2Type(weapon2Id)); const lv = Math.max(1, Math.floor(Number(level) || 1)); if (w2Type < 1 || w2Type > 5) { return { weapon2Id, level: lv, reason: PlaceRejectReason.InvalidMerge }; } if (kind === BattleBagCardKind.Multiplier) { const cfg = resolveCarriedMultiplierCard(this._currentCarryPassives()); if (!cfg) { console.log('[BattleBagSession] 倍率卡配置缺失'); return { weapon2Id, level: lv, reason: PlaceRejectReason.InvalidMerge }; } const delta = rollMultiplierCardLevelDelta(cfg); if (delta <= 0) return { weapon2Id, level: lv, reason: PlaceRejectReason.InvalidMerge }; const maxLv = WeaponComposeConfigProvider.instance.getElementMaxLevel(w2Type); const newLv = Math.min(maxLv, lv + delta); if (newLv <= lv) return { weapon2Id, level: lv, reason: PlaceRejectReason.MaxLevel }; return { weapon2Id, level: newLv, reason: null }; } const rolled = this._textPool.pickAttrSwap(weapon2Id, lv); if (!rolled) return { weapon2Id, level: lv, reason: PlaceRejectReason.InvalidMerge }; return { weapon2Id: rolled.weapon2Id, level: rolled.level, reason: null }; } /** * 拖拽源上的五行字 id 与显示等级。 * @param source 拖拽源 */ private _sourcePiece(source: DragSource): { weapon2Id: number; level: number } { if (source.kind === 'bag') { const slot = this.merge.bag.getSlot(source.slotIndex); return { weapon2Id: Number(slot?.weapon2Id) || 0, level: Math.floor(Number(slot?.level) || 0), }; } const tile = this.merge.map.getTile(source.gridIndex); return { weapon2Id: Number(tile?.weapon2Id) || 0, level: Math.floor(Number(tile?.level) || 0), }; } /** * 解锁判定用的英雄类型与显示等级(Text.type + 字块 level)。 * @param piece 文字 id 与显示等级 */ private _heroOfGlyph(piece: { weapon2Id: number; level: number }): AreaUnlockHero | null { const heroType = Math.floor(WeaponComposeConfigProvider.instance.getWeapon2Type(piece.weapon2Id)); if (heroType < 1 || heroType > 5) return null; if (!(piece.level > 0)) { console.warn('[BattleBagSession] 解锁拖动缺少显示等级', piece.weapon2Id); return null; } return { heroType, level: piece.level }; } /** * 仅同步 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 { const out = new Map(); 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(); 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, ): void { const indices = createMembershipSet(); 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 _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; firstBattlePending: number; textPool: PlayerBagTextPoolState; } { return { adRefreshUsed: 0, normalRefreshUsed: this._normalRefreshUsed, firstBattlePending: this._firstHeroPity.pendingPartner, textPool: this._textPool.exportState(), }; } importCounters(counters: { adRefreshUsed: number; normalRefreshUsed: number; firstBattlePending?: number; textPool?: PlayerBagTextPoolState; }): void { this._normalRefreshUsed = counters.normalRefreshUsed; 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 '新产品不支持放置格子'; }