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.
1645 lines
68 KiB
1645 lines
68 KiB
import {
|
|
BattleBagCardKind,
|
|
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 { getDiscountedBagNormalRefreshCost } 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 type { AreaUnlockHero } from '../map/legacy/MapAreaUnlock';
|
|
import { DailyShopDataManager } from '../../manager/DailyShopDataManager';
|
|
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';
|
|
/** 倍率卡占位 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';
|
|
import { getTowerExpHost } from '../combat/TowerExpHost';
|
|
import { findEntityIdByGridIndex } from '../runtime/DeployEntityQuery';
|
|
/** 召唤按钮一次最多补的空开启格数量 */
|
|
const REFRESH_EMPTY_MAX = 5;
|
|
|
|
/** 项目层背包会话:桥接 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[] = [];
|
|
/**
|
|
* 寻金尺飞币回调(入账后通知表现层)。
|
|
* @param gridIndex 解锁锚点格
|
|
* @param coin 本次随机金币
|
|
* @param ownerPlayerId 归属玩家
|
|
*/
|
|
onUnlockCoinFly: ((gridIndex: number, coin: number, ownerPlayerId: number) => void) | null = null;
|
|
/** 本局携带被动:召唤消耗降低万分比(开局快照) */
|
|
private readonly _summonCostReduceWan: number;
|
|
/** 本局携带被动快照(开局) */
|
|
private readonly _carryPassives: readonly DailyShopParsed[];
|
|
|
|
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);
|
|
// 携带被动按 ownerPlayerId:本机 DailyShop,AI 读 CarryStore
|
|
if (this._ownerPlayerId === 0) {
|
|
this._carryPassives = DailyShopDataManager.getOwnedPassive();
|
|
this._summonCostReduceWan = resolveCarriedSummonCostReduceWan(this._carryPassives).reduceWan;
|
|
this._textPool.beginBattle(rankId, {});
|
|
} else {
|
|
this._carryPassives = BattleBuffCarryStore.getPassives(this._ownerPlayerId);
|
|
this._summonCostReduceWan = resolveCarriedSummonCostReduceWan(this._carryPassives).reduceWan;
|
|
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;
|
|
}
|
|
|
|
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);
|
|
}
|
|
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 开启但留空;未解锁与 grid17 不填。不占刷新次数;玩家/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 待开启槽下标
|
|
* @returns 是否写入成功
|
|
*/
|
|
fillGateUnlockSlot(slotIndex: number): boolean {
|
|
if (this.landStateOf?.(slotIndex) !== TextListCellState.OpenNoDrag) {
|
|
console.log('[BattleBagSession] 非待开启格不出字', slotIndex);
|
|
return false;
|
|
}
|
|
const roll = this._textPool.rollOneForLand(true);
|
|
if (roll == null) {
|
|
console.log('[BattleBagSession] 待开启格字池抽空', slotIndex);
|
|
return false;
|
|
}
|
|
this.merge.bag.setSlot(slotIndex, { weapon2Id: roll.weapon2Id, level: roll.level });
|
|
return true;
|
|
}
|
|
|
|
/** 两个字是否五行类型相同且显示等级相同 */
|
|
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.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';
|
|
}
|
|
}
|
|
|
|
/** 最近一次空格刷新写入的槽 */
|
|
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 = 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 {
|
|
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 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;
|
|
}
|
|
}
|
|
}
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 蟠桃树被动落点:优先空 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<MergeSession['revertType3Pickup']>[1]): void {
|
|
this.merge.revertType3Pickup(gridIndex, tile);
|
|
this._onWalkabilityCellChanged(gridIndex, true);
|
|
}
|
|
|
|
/**
|
|
* 地图字拖到空装饰格时删掉,不退回字池。
|
|
* @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;
|
|
}
|
|
|
|
/**
|
|
* 背包字拖到空装饰格时删掉,不退回字池。
|
|
* @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 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;
|
|
}
|
|
}
|
|
}
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 用关卡 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 }));
|
|
}
|
|
|
|
/**
|
|
* 五行字拖到当前解锁区域:类型/显示等级符合则整区可放置并播 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 hit = this._map.areaUnlock.tryDrag(grid, this._heroOfGlyph(piece));
|
|
if (hit === 'skip') return 'skip';
|
|
if (hit === 'blocked') 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);
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* 当前携带被动(本机每次读 DailyShop,便于 GM 改携带后立刻生效)。
|
|
*/
|
|
private _currentCarryPassives(): readonly DailyShopParsed[] {
|
|
if (this._ownerPlayerId === 0) {
|
|
return DailyShopDataManager.getOwnedPassive();
|
|
}
|
|
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}`);
|
|
}
|
|
|
|
/**
|
|
* 倍率卡/属性卡拖到五行数字上生效。
|
|
* @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 };
|
|
}
|
|
|
|
/**
|
|
* 字块离开地图前,把当前英雄状态写到字上(进背包后仍能恢复)。
|
|
* @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 _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 '新产品不支持放置格子';
|
|
}
|
|
|
|
|