You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
AdLike2048/assets/mx/module/merge/core/MergeSession.ts

2285 lines
94 KiB

3 weeks ago
import { BagBoard } from '../board/BagBoard';
import { MapTileBoard } from '../board/MapTileBoard';
import type { IComposeConfigProvider } from '../config/IComposeConfigProvider';
import { ComposeMatcher, type GridCoord } from '../compose/ComposeMatcher';
import { MergeEventFactory, type MergeEvent, type MergeEventListener } from '../events/MergeEvents';
import { MergeEventType, MERGE_EVENT_ALL, type MergeEventSubscription } from '../events/MergeEventType';
import {
canDragMapTile,
canDragPrepType3Cell,
canDropWeapon2OnCell,
cellKindAfterReturnToBag,
cellKindAfterType3Pickup,
cellKindAfterType3Place,
} from '../rules/MapPlaceRules';
import { UpgradeSystem } from '../upgrade/UpgradeSystem';
import {
MapCellKind,
PlaceRejectReason,
TileSource,
Weapon2TileType,
type BagSlotRecord,
type ComposedUnit,
type DragSource,
type DropTarget,
type GlyphHeroBind,
type GlyphHeroBuff,
type MapTileRecord,
} from '../types/MergeTypes';
import {
createMembershipSet,
membershipAdd,
membershipForEach,
type MembershipSet,
} from '../../../tools/MembershipSet';
/** Weapon2.id — 扩展地图可放置区域(type3 仅改格状态,不挂字块) */
const GRID_WEAPON2_ID = 5;
/** 地图路径校验(项目层注入 MapManager) */
export interface IMapPathChecker {
/** 假设 gridIndex 被 type3 占用后路径是否仍通 */
canPlaceType3Grid(gridIndex: number): boolean;
/** 该格是否允许放 type3(读 live MapGrid,避免 MapTileBoard 快照过期) */
isCanPutGrid?(gridIndex: number): boolean;
/** 该格是否被蜘蛛精蛛网占用 */
isSpiderWebBlocked?(gridIndex: number): boolean;
/** 读 MapGrid 现场格语义 */
getLiveCellKind?(gridIndex: number): MapCellKind | null;
}
/**
* 背包合成 + 地图放置会话(纯 TS)
* 仅在 PREP 阶段由项目层驱动。
*/
export class MergeSession {
readonly bag: BagBoard;
readonly map: MapTileBoard;
private readonly _config: IComposeConfigProvider;
private readonly _matcher: ComposeMatcher;
private readonly _upgrade: UpgradeSystem;
private readonly _pathChecker: IMapPathChecker | null;
private readonly _coordOf: (gridIndex: number) => GridCoord | null;
private _prepActive = false;
private _compositions: ComposedUnit[] = [];
private _compositionLevels = new Map<string, number>();
private _listeners = new Map<MergeEventSubscription, MembershipSet<MergeEventListener>>();
/** type3 拖起后暂存,直到落子或还原 */
private _pendingDragTile: MapTileRecord | null = null;
/** 规划仿真等:抑制 emit,避免表现层打点/重建 */
private _emitSuppressed = false;
/** 降级等:该格禁止合成升级 */
private _mergeBlocker: { isMergeUpgradeBlocked(gridIndex: number): boolean } | null = null;
constructor(options: {
config: IComposeConfigProvider;
pathChecker?: IMapPathChecker;
coordOf: (gridIndex: number) => GridCoord | null;
bagSlotCount?: number;
}) {
this._config = options.config;
this._pathChecker = options.pathChecker ?? null;
this._coordOf = options.coordOf;
this.bag = new BagBoard(options.bagSlotCount ?? 5);
this.map = new MapTileBoard();
this._matcher = new ComposeMatcher(this._config);
this._upgrade = new UpgradeSystem(this._config);
}
get compositions(): readonly ComposedUnit[] {
return this._compositions;
}
get prepActive(): boolean {
return this._prepActive;
}
/**
* 注入合成升级拦截。
* @param blocker 查询某格是否禁合成;null 清除
*/
setMergeBlocker(blocker: { isMergeUpgradeBlocked(gridIndex: number): boolean } | null): void {
this._mergeBlocker = blocker;
}
/**
* 该格是否禁止合成升级。
* @param gridIndex 地图格
*/
isMergeUpgradeBlocked(gridIndex: number): boolean {
return this._mergeBlocker?.isMergeUpgradeBlocked(gridIndex) === true;
}
beginPrepRound(): void {
this._prepActive = true;
}
endPrepRound(): void {
this._prepActive = false;
}
on(event: MergeEventSubscription, listener: MergeEventListener): void {
if (!this._listeners.has(event)) {
this._listeners.set(event, createMembershipSet());
}
membershipAdd(this._listeners.get(event)!, listener);
}
off(event: MergeEventSubscription, listener: MergeEventListener): void {
this._listeners.get(event)?.delete(listener);
}
refreshBag(slots: BagSlotRecord[]): void {
this.bag.fillSlots(slots);
// 入场动画由 UI(rebuildBagSlots + fromWorld)负责;此处只同步数据,避免抢播/双重建
this.emit(MergeEventFactory.bagRefreshed(false));
}
/** 免费格子:放入背包第一个空槽 */
grantGridInBag(weapon2Id: number): boolean {
const slot = this.bag.findEmptySlot();
if (slot < 0) return false;
return this.grantWeaponInBagSlot(slot, weapon2Id) == null;
}
/**
* 指定背包槽放入 weapon2(槽位须为空)。
* @returns null 表示成功
*/
grantWeaponInBagSlot(slotIndex: number, weapon2Id: number): PlaceRejectReason | null {
if (!this._prepActive) return PlaceRejectReason.NotPrepPhase;
if (slotIndex < 0 || slotIndex >= this.bag.slotCount) {
return PlaceRejectReason.InvalidMerge;
}
if (this.bag.getSlot(slotIndex)) return PlaceRejectReason.BagFull;
this.bag.setSlot(slotIndex, { weapon2Id });
this.emit(MergeEventFactory.bagRefreshed(false));
return null;
}
/** 免费格子:直接扩展 type3 到指定地图格(仅改格状态) */
placeGridOnMap(gridIndex: number, weapon2Id: number): PlaceRejectReason | null {
if (!this._prepActive) return PlaceRejectReason.NotPrepPhase;
const w2Type = this._config.getWeapon2Type(weapon2Id) as Weapon2TileType;
if (w2Type !== Weapon2TileType.Grid) return PlaceRejectReason.WrongCellKind;
const cellKind = this.map.getCellKind(gridIndex);
if (!this._canDropOnCell(gridIndex, Weapon2TileType.Grid)) {
return this._dropRejectReason(gridIndex, Weapon2TileType.Grid);
}
const ok = this._pathChecker?.canPlaceType3Grid(gridIndex) ?? true;
if (!ok) return PlaceRejectReason.PathBlocked;
this._applyType3Expand(gridIndex);
this.emit(MergeEventFactory.tilePlaced(gridIndex, weapon2Id));
return null;
}
/** 点石成金:直接对可开挖格使用铲(背包满时的兜底) */
placeShovelOnMap(gridIndex: number, weapon2Id: number): PlaceRejectReason | null {
if (!this._prepActive) return PlaceRejectReason.NotPrepPhase;
const w2Type = this._config.getWeapon2Type(weapon2Id) as Weapon2TileType;
if (w2Type !== Weapon2TileType.Shovel) return PlaceRejectReason.WrongCellKind;
if (!this._canDropOnCell(gridIndex, Weapon2TileType.Shovel)) {
return this._dropRejectReason(gridIndex, Weapon2TileType.Shovel);
}
this._applyType4DigConvert(gridIndex);
this.emit(MergeEventFactory.tilePlaced(gridIndex, weapon2Id));
return null;
}
/**
* 被动 buff:在空 Dig 或空 Put 格生成字块。
* 默认 Dig→Put + placeTile;蟠桃树落到 Put 则 Put→Dig,Dig 格保持 Dig。
* @param gridIndex 目标地图格
* @param weapon2Id Weapon2 文字 id
*/
grantPassiveTileOnDigGrid(gridIndex: number, weapon2Id: number): PlaceRejectReason | null {
if (!this._prepActive) return PlaceRejectReason.NotPrepPhase;
const w2Type = this._config.getWeapon2Type(weapon2Id) as Weapon2TileType;
if (!this._config.allowsPassiveDigSpawn(weapon2Id)) {
console.log('[MergeSession] 被动产字 weapon2 类型非法', weapon2Id, w2Type);
return PlaceRejectReason.WrongCellKind;
}
const keepDigCell = this._config.passiveSpawnKeepsDigCell(weapon2Id);
if (keepDigCell) {
if (!this._canDropKeepDigOnCell(gridIndex)) return PlaceRejectReason.WrongCellKind;
this._convertPutToDig(gridIndex);
} else {
const cellKind = this.map.getCellKind(gridIndex);
if (cellKind !== MapCellKind.Dig) return PlaceRejectReason.WrongCellKind;
if (this.map.hasTile(gridIndex)) return PlaceRejectReason.CellOccupied;
this._applyType4DigConvert(gridIndex);
}
const weapon1Id = this._config.getWeapon1ByGroupSequence([weapon2Id]) ?? undefined;
const tile: MapTileRecord = {
weapon2Id,
source: TileSource.PassiveFunction3,
weapon1Id,
level: 1,
};
this.map.placeTile(gridIndex, tile);
this._recompose();
this.emit(MergeEventFactory.tilePlaced(gridIndex, weapon2Id));
return null;
}
/**
* 从地图拖起校验。
* type3:仅 prepType3 格状态,无字块节点;拖起后恢复可行走。
*/
pickupFromMap(gridIndex: number): { error: PlaceRejectReason } | { tile: MapTileRecord; wasType3: boolean } {
const tile = this.map.getTile(gridIndex);
if (this.map.isPrepType3(gridIndex) && !tile) {
if (!canDragPrepType3Cell(true, false, this.map.getPrepType3PlacedAtMs(gridIndex))) {
return { error: PlaceRejectReason.NotDraggable };
}
this._applyType3Revert(gridIndex);
const virtualTile: MapTileRecord = {
weapon2Id: GRID_WEAPON2_ID,
source: TileSource.PrepPlaced,
};
this._pendingDragTile = { ...virtualTile };
return { tile: virtualTile, wasType3: true };
}
if (!tile) return { error: PlaceRejectReason.SlotEmpty };
if (this._config.passiveSpawnKeepsDigCell(tile.weapon2Id)) {
return { tile: { ...tile }, wasType3: false };
}
const w2Type = this._config.getWeapon2Type(tile.weapon2Id) as Weapon2TileType;
const isBuiltin = this.map.isBuiltinPut(gridIndex) && !this.map.hasTile(gridIndex);
if (!canDragMapTile(tile, w2Type, isBuiltin)) {
return { error: PlaceRejectReason.NotDraggable };
}
return { tile: { ...tile }, wasType3: false };
}
/** type3 拖放失败后还原格状态 */
revertType3Pickup(gridIndex: number, _tile: MapTileRecord): void {
this._pendingDragTile = null;
this._applyType3Expand(gridIndex);
}
/**
* 落子(背包→地图 或 地图→地图 或 地图→背包)
* @param opts.silent 仅改盘面数据、不 emit(自动合成规划仿真用;调用方须自行 restore)
*/
drop(source: DragSource, target: DropTarget, opts?: { silent?: boolean }): PlaceRejectReason | null {
if (!this._prepActive) {
return PlaceRejectReason.NotPrepPhase;
}
const silent = !!opts?.silent;
if (silent) this._emitSuppressed = true;
try {
if (source.kind === 'bag' && target.kind === 'bag') {
return this._dropBagToBag(source.slotIndex, target.slotIndex);
}
if (source.kind === 'bag' && target.kind === 'map') {
return this._dropBagToMap(source.slotIndex, target.gridIndex);
}
if (source.kind === 'map' && target.kind === 'map') {
return this._dropMapToMap(source.gridIndex, target.gridIndex);
}
if (source.kind === 'map' && target.kind === 'bag') {
return this._dropMapToBag(source.gridIndex, target.slotIndex);
}
return PlaceRejectReason.WrongCellKind;
} finally {
if (silent) this._emitSuppressed = false;
}
}
private _applyType3Expand(gridIndex: number): void {
this.map.setCellKind(gridIndex, cellKindAfterType3Place());
this.map.setPrepType3(gridIndex, true);
}
private _applyType3Revert(gridIndex: number): void {
this.map.setPrepType3(gridIndex, false);
this.map.setCellKind(gridIndex, cellKindAfterType3Pickup());
}
/** type4 铲:可开挖格 → 可放置,不挂字块(一次性消耗) */
private _applyType4DigConvert(gridIndex: number): void {
this.map.markCellAsPut(gridIndex);
}
private _canDropOnCell(gridIndex: number, w2Type: Weapon2TileType): boolean {
if (this._pathChecker?.isSpiderWebBlocked?.(gridIndex)) {
return false;
}
const cellKind = this._pathChecker?.getLiveCellKind?.(gridIndex)
?? this.map.getCellKind(gridIndex);
if (cellKind == null) return false;
const canPutGrid = this._pathChecker?.isCanPutGrid?.(gridIndex)
?? this.map.canPutGrid(gridIndex);
return canDropWeapon2OnCell(
w2Type,
cellKind,
this.map.hasTile(gridIndex),
canPutGrid,
);
}
private _dropRejectReason(gridIndex: number, w2Type: Weapon2TileType): PlaceRejectReason {
if (this._pathChecker?.isSpiderWebBlocked?.(gridIndex)) {
return w2Type === Weapon2TileType.Grid
? PlaceRejectReason.SpiderWebBlocked
: PlaceRejectReason.WrongCellKind;
}
if (w2Type === Weapon2TileType.Grid) {
return this._type3DropRejectReason(gridIndex);
}
return PlaceRejectReason.WrongCellKind;
}
private _type3DropRejectReason(gridIndex: number): PlaceRejectReason {
if (this._pathChecker?.isSpiderWebBlocked?.(gridIndex)) {
return PlaceRejectReason.SpiderWebBlocked;
}
return PlaceRejectReason.WrongCellKind;
}
private _dropBagToBag(fromSlot: number, toSlot: number): PlaceRejectReason | null {
if (fromSlot === toSlot) return null;
const src = this.bag.getSlot(fromSlot);
const dst = this.bag.getSlot(toSlot);
if (!src) return PlaceRejectReason.SlotEmpty;
if (!dst) {
this.bag.setSlot(toSlot, src);
this.bag.clearSlot(fromSlot);
this.emit(MergeEventFactory.bagSlotMoved(fromSlot, toSlot));
return null;
}
if (this._config.passiveSpawnKeepsDigCell(src.weapon2Id)
&& this._config.passiveSpawnKeepsDigCell(dst.weapon2Id)) {
const merged = this._tryKeepDigBagBagMerge(fromSlot, toSlot, src, dst);
if (merged === null) return null;
if (merged !== 'skip') return merged;
this.bag.setSlot(toSlot, src);
this.bag.setSlot(fromSlot, dst);
this.emit(MergeEventFactory.bagSlotMoved(fromSlot, toSlot));
return null;
}
const err = this._upgrade.canMergeInBag(src, dst);
if (!err) {
const merged = this._upgrade.mergeInBag(dst);
const weapon1Id = merged.weapon1Id
?? this._config.getWeapon1ByGroupSequence([merged.weapon2Id]);
if (weapon1Id == null) return PlaceRejectReason.InvalidMerge;
this.bag.setSlot(toSlot, { ...merged, weapon1Id });
this.bag.clearSlot(fromSlot);
this.emit(MergeEventFactory.bagMerged(toSlot, weapon1Id, merged.level ?? 1));
return null;
}
// 不能合成(异级/满级等)→ 交换位置
this.bag.setSlot(toSlot, src);
this.bag.setSlot(fromSlot, dst);
this.emit(MergeEventFactory.bagSlotMoved(fromSlot, toSlot));
return null;
}
private _dropBagToMap(slotIndex: number, gridIndex: number): PlaceRejectReason | null {
const slot = this.bag.getSlot(slotIndex);
if (!slot) return PlaceRejectReason.SlotEmpty;
if (this._config.passiveSpawnKeepsDigCell(slot.weapon2Id)) {
return this._dropKeepDigBagToMap(slotIndex, gridIndex, slot);
}
const w2Type = Number(this._config.getWeapon2Type(slot.weapon2Id)) as Weapon2TileType;
if (w2Type === Weapon2TileType.Grid) {
if (!this._canDropOnCell(gridIndex, w2Type)) {
return this._dropRejectReason(gridIndex, w2Type);
}
const ok = this._pathChecker?.canPlaceType3Grid(gridIndex) ?? true;
if (!ok) return PlaceRejectReason.PathBlocked;
this._applyType3Expand(gridIndex);
this.bag.clearSlot(slotIndex);
this.emit(MergeEventFactory.tilePlaced(gridIndex, slot.weapon2Id));
return null;
}
if (w2Type === Weapon2TileType.Shovel) {
if (!this._canDropOnCell(gridIndex, w2Type)) {
return this._dropRejectReason(gridIndex, w2Type);
}
this._applyType4DigConvert(gridIndex);
this.bag.clearSlot(slotIndex);
this.emit(MergeEventFactory.tilePlaced(gridIndex, slot.weapon2Id));
return null;
}
const existingCompose = this._findCompositionAt(gridIndex);
if (existingCompose) {
const upgraded = this._tryUpgradeOnComposition(
slot,
gridIndex,
() => this.bag.clearSlot(slotIndex),
);
if (upgraded === null) return null;
if (upgraded !== 'skip') return upgraded;
}
const targetTile = this.map.getTile(gridIndex);
if (targetTile) {
const merged = this._tryBagMapSingleMerge(slotIndex, gridIndex, slot, targetTile);
if (merged === null) return null;
if (merged !== 'skip') return merged;
const swapped = this._tryBagMapSwap(slotIndex, gridIndex, slot, targetTile);
if (swapped !== 'skip') return swapped;
}
if (!this._canDropOnCell(gridIndex, w2Type)) {
return this._dropRejectReason(gridIndex, w2Type);
}
const tile = this._mapTileFromBagSlot(slot, TileSource.BagRefresh);
this.map.placeTile(gridIndex, tile);
this.bag.clearSlot(slotIndex);
this._recompose();
this.emit(MergeEventFactory.tilePlaced(gridIndex, slot.weapon2Id));
return null;
}
private _dropMapToMap(fromGrid: number, toGrid: number): PlaceRejectReason | null {
const pendingType3 = this._pendingDragTile?.source === TileSource.PrepPlaced
&& this._config.getWeapon2Type(this._pendingDragTile.weapon2Id) === Weapon2TileType.Grid;
if (fromGrid === toGrid) {
if (pendingType3) {
this._applyType3Expand(fromGrid);
}
this._pendingDragTile = null;
return null;
}
const tile = this.map.getTile(fromGrid) ?? this._pendingDragTile;
if (!tile) return PlaceRejectReason.SlotEmpty;
const w2Type = this._config.getWeapon2Type(tile.weapon2Id) as Weapon2TileType;
if (w2Type === Weapon2TileType.Grid && tile.source === TileSource.PrepPlaced) {
if (!this._canDropOnCell(toGrid, Weapon2TileType.Grid)) {
return this._dropRejectReason(toGrid, Weapon2TileType.Grid);
}
const ok = this._pathChecker?.canPlaceType3Grid(toGrid) ?? true;
if (!ok) return PlaceRejectReason.PathBlocked;
if (this.map.isPrepType3(fromGrid)) {
this._applyType3Revert(fromGrid);
}
this._applyType3Expand(toGrid);
this._pendingDragTile = null;
this.emit(MergeEventFactory.tileMoved(fromGrid, toGrid));
return null;
}
this._pendingDragTile = null;
if (this._keepsDigCell(tile)) {
return this._dropMapToMapKeepDig(fromGrid, toGrid, tile);
}
const composeAtTarget = this._findCompositionAt(toGrid);
if (composeAtTarget) {
const upgraded = this._tryUpgradeOnComposition(
tile,
toGrid,
() => {},
fromGrid,
);
if (upgraded === null) return null;
if (upgraded !== 'skip') return upgraded;
}
const targetTile = this.map.getTile(toGrid);
if (targetTile) {
const merged = this._tryMapToMapSingleMerge(fromGrid, toGrid, tile, targetTile);
if (merged === null) return null;
if (merged !== 'skip') return merged;
const swapped = this._tryMapToMapSwap(fromGrid, toGrid, tile, targetTile);
if (swapped !== 'skip') return swapped;
}
if (!this._canDropOnCell(toGrid, w2Type)) {
return this._dropRejectReason(toGrid, w2Type);
}
this.map.removeTile(fromGrid);
this.map.setCellKind(fromGrid, cellKindAfterReturnToBag());
this.map.placeTile(toGrid, { ...tile });
this._recompose();
this.emit(MergeEventFactory.tileMoved(fromGrid, toGrid));
return null;
}
private _dropMapToBag(fromGrid: number, toSlot: number): PlaceRejectReason | null {
const tile = this.map.getTile(fromGrid);
const bagSlot = this.bag.getSlot(toSlot);
if (this.map.isPrepType3(fromGrid) && !tile) {
this._applyType3Revert(fromGrid);
this.bag.setSlot(toSlot, { weapon2Id: GRID_WEAPON2_ID });
this._pendingDragTile = null;
this.emit(MergeEventFactory.tileReturned(fromGrid, toSlot));
return null;
}
if (!tile) return PlaceRejectReason.SlotEmpty;
if (this._config.passiveSpawnKeepsDigCell(tile.weapon2Id)) {
return this._dropKeepDigMapToBag(fromGrid, toSlot, tile, bagSlot);
}
const w2Type = this._config.getWeapon2Type(tile.weapon2Id) as Weapon2TileType;
const isBuiltin = false;
if (!canDragMapTile(tile, w2Type, isBuiltin)) {
return PlaceRejectReason.NotDraggable;
}
if (bagSlot) {
const merged = this._tryMapBagSingleMerge(fromGrid, toSlot, tile, bagSlot);
if (merged === null) return null;
if (merged !== 'skip') return merged;
const swapped = this._tryMapBagSwap(fromGrid, toSlot, tile, bagSlot);
if (swapped !== 'skip') return swapped;
return PlaceRejectReason.WrongCellKind;
}
this.map.removeTile(fromGrid);
this.map.setCellKind(fromGrid, cellKindAfterReturnToBag());
this.bag.setSlot(toSlot, this._bagSlotFromMapTile(tile));
this._recompose();
this.emit(MergeEventFactory.tileReturned(fromGrid, toSlot));
return null;
}
/** 地图 type1 单字 → 背包同 Weapon1 同等级字块:合成升级(消耗地图字,背包升 1 级) */
private _tryMapBagSingleMerge(
fromGrid: number,
toSlot: number,
mapTile: MapTileRecord,
bagSlot: BagSlotRecord,
): PlaceRejectReason | null | 'skip' {
const source: BagSlotRecord = {
weapon2Id: mapTile.weapon2Id,
weapon1Id: mapTile.weapon1Id,
level: mapTile.level,
};
// 异级 / 满级 / 非同字:交给后续交换
if (this._upgrade.canMergeInBag(source, bagSlot)) return 'skip';
if (this.isMergeUpgradeBlocked(fromGrid)) return 'skip';
const merged = this._upgrade.mergeInBag(bagSlot);
const weapon1Id = merged.weapon1Id
?? this._config.getWeapon1ByGroupSequence([merged.weapon2Id]);
if (weapon1Id == null) return PlaceRejectReason.InvalidMerge;
this.map.removeTile(fromGrid);
this.map.setCellKind(fromGrid, cellKindAfterReturnToBag());
this.bag.setSlot(toSlot, { ...merged, weapon1Id });
this._recompose();
this.emit(MergeEventFactory.bagMerged(toSlot, weapon1Id, merged.level!));
return null;
}
/** 两个背包/地图字是否为可尝试合成的同类单字(含等级不符、满级) */
private _isSameSingleWeaponPair(
a: { weapon2Id: number; weapon1Id?: number },
b: { weapon2Id: number; weapon1Id?: number },
): boolean {
const srcType = this._config.getWeapon2Type(a.weapon2Id);
const dstType = this._config.getWeapon2Type(b.weapon2Id);
if (srcType !== 1 || dstType !== 1) return false;
const srcW1 = a.weapon1Id ?? this._config.getWeapon1ByGroupSequence([a.weapon2Id]);
const dstW1 = b.weapon1Id ?? this._config.getWeapon1ByGroupSequence([b.weapon2Id]);
if (srcW1 == null || dstW1 == null || srcW1 !== dstW1) return false;
return this._config.isSingleCellWeapon1(srcW1);
}
/**
* 拖字块到已组合单位上升级。
* - type1 单字:来源与组合同 Weapon1、同等级,整组升 1 级
* - type2 组合字:拖入 group 内任意一字(通常为 1 级),整组升 1 级,不要求与组合等级相同
*/
private _tryUpgradeOnComposition(
source: { weapon2Id: number; weapon1Id?: number; level?: number },
dropGridIndex: number,
onConsumeSource: () => void,
fromMapGrid?: number,
): PlaceRejectReason | null | 'skip' {
const compose = this._findCompositionAt(dropGridIndex);
if (!compose) return 'skip';
if (this._config.isSingleCellWeapon1(compose.weapon1Id)) {
return this._tryUpgradeSingleOnComposition(source, compose, onConsumeSource, fromMapGrid);
}
return this._tryUpgradeComboOnComposition(source, compose, onConsumeSource, fromMapGrid);
}
private _tryUpgradeSingleOnComposition(
source: { weapon2Id: number; weapon1Id?: number; level?: number },
compose: ComposedUnit,
onConsumeSource: () => void,
fromMapGrid?: number,
): PlaceRejectReason | null | 'skip' {
const srcW2Type = this._config.getWeapon2Type(source.weapon2Id) as Weapon2TileType;
if (srcW2Type !== Weapon2TileType.Single) return 'skip';
const srcW1 = source.weapon1Id ?? this._config.getWeapon1ByGroupSequence([source.weapon2Id]);
if (srcW1 == null || srcW1 !== compose.weapon1Id) return 'skip';
const gridIdx = compose.gridIndices[0];
const targetTile = this.map.getTile(gridIdx);
if (!targetTile) return PlaceRejectReason.InvalidMerge;
const composeLevel = targetTile.level ?? 1;
const sourceLevel = source.level ?? 1;
// 异级:不升级,交给后续交换
if (sourceLevel !== composeLevel) return 'skip';
const max = this._config.getMaxWeaponLevel(compose.weapon1Id);
// 满级:不升级,交给后续交换
if (composeLevel >= max) return 'skip';
if (this.isMergeUpgradeBlocked(gridIdx)) return 'skip';
// 同格/同组合内互拖:不升级,交给后续交换
if (fromMapGrid != null && compose.gridIndices.includes(fromMapGrid)) {
return 'skip';
}
const sourceSiblings = fromMapGrid != null
? this._resolveSourceCompositionSiblings(fromMapGrid)
: [];
const newLevel = Math.min(max, this._upgrade.upgradeLevel(composeLevel));
const tile = targetTile;
if (tile) {
this.map.placeTile(gridIdx, {
...tile,
weapon1Id: compose.weapon1Id,
level: newLevel,
});
}
if (fromMapGrid != null) {
this._dissolveCompositionSiblings(sourceSiblings);
this.map.removeTile(fromMapGrid);
this.map.setCellKind(fromMapGrid, cellKindAfterReturnToBag());
}
onConsumeSource();
this._setCompositionLevel(compose, newLevel);
this._recompose();
this.emit(MergeEventFactory.mapUpgraded(gridIdx, compose.weapon1Id, newLevel));
return null;
}
/**
* type2 组合字:拖入 group 内任意一字,整组升 1 级,可落在组合任意格上。
* 背包/地图材料均不要求与组合同级;满级则交给后续交换。
*/
private _tryUpgradeComboOnComposition(
source: { weapon2Id: number; level?: number },
compose: ComposedUnit,
onConsumeSource: () => void,
fromMapGrid?: number,
): PlaceRejectReason | null | 'skip' {
const srcW2Type = this._config.getWeapon2Type(source.weapon2Id) as Weapon2TileType;
if (srcW2Type !== Weapon2TileType.Combo) return 'skip';
const group = this._config.getWeapon1Group(compose.weapon1Id);
if (!group.includes(source.weapon2Id)) return 'skip';
// 同组合内互拖:不升级,交给后续交换(避免「无法升级」挡掉换位)
if (fromMapGrid != null && compose.gridIndices.includes(fromMapGrid)) {
return 'skip';
}
const composeLevel = this._maxTileLevelInCompose(compose);
const max = this._config.getMaxWeaponLevel(compose.weapon1Id);
// 满级:不能升级,交给后续交换
if (composeLevel >= max) return 'skip';
const sourceSiblings = fromMapGrid != null
? this._resolveSourceCompositionSiblings(fromMapGrid)
: [];
const newLevel = Math.min(max, this._upgrade.upgradeLevel(composeLevel));
this._applyLevelToCompositionTiles(compose, newLevel);
if (fromMapGrid != null) {
this._dissolveCompositionSiblings(sourceSiblings);
this.map.removeTile(fromMapGrid);
this.map.setCellKind(fromMapGrid, cellKindAfterReturnToBag());
}
onConsumeSource();
this._setCompositionLevel(compose, newLevel);
compose.level = newLevel;
this._recompose();
this.emit(MergeEventFactory.mapUpgraded(compose.gridIndices[0], compose.weapon1Id, newLevel));
return null;
}
/** 组合内所有字块设为同一等级,并同步绑定快照等级 */
private _applyLevelToCompositionTiles(compose: ComposedUnit, level: number): void {
for (const idx of compose.gridIndices) {
const tile = this.map.getTile(idx);
if (!tile) continue;
if ((tile.level ?? 1) === level && tile.heroBind?.level === level) continue;
const rec: MapTileRecord = { ...tile, level };
if (tile.heroBind) rec.heroBind = { ...this._cloneHeroBind(tile.heroBind), level };
this.map.placeTile(idx, rec);
}
}
/** 是否为多格组合武器(非 type1 单字) */
private _isMultiCellComposition(c: ComposedUnit): boolean {
return !this._config.isSingleCellWeapon1(c.weapon1Id) && c.gridIndices.length >= 2;
}
private _maxTileLevelInCompose(compose: ComposedUnit): number {
let max = 1;
for (const idx of compose.gridIndices) {
const tile = this.map.getTile(idx);
const lv = Math.max(tile?.heroBind?.level ?? 1, tile?.level ?? 1);
if (lv > max) max = lv;
}
return max;
}
/** 文字是否来自 functiontype=3,可在未开挖格移动并参与同类合成 */
private _isFunction3Tile(tile: MapTileRecord): boolean {
return tile.source === TileSource.PassiveFunction3;
}
/** functiontype=3 产字:默认仅空 Dig;蟠桃树另含空 Put */
private _canDropFunction3OnCell(gridIndex: number, weapon2Id: number): boolean {
if (this._config.passiveSpawnKeepsDigCell(weapon2Id)) {
return this._canDropKeepDigOnCell(gridIndex);
}
if (this._pathChecker?.isSpiderWebBlocked?.(gridIndex)) return false;
const cellKind = this._pathChecker?.getLiveCellKind?.(gridIndex)
?? this.map.getCellKind(gridIndex);
return cellKind === MapCellKind.Dig && !this.map.hasTile(gridIndex);
}
/** functiontype=3 产字:空格移动或与目标文字合成 */
private _dropFunction3MapToMap(fromGrid: number, toGrid: number, tile: MapTileRecord): PlaceRejectReason | null {
const targetTile = this.map.getTile(toGrid);
if (targetTile) {
const merged = this._tryKeepDigMerge(fromGrid, toGrid, tile, targetTile);
if (merged === null) return null;
if (merged !== 'skip') return merged;
}
if (!this._canDropFunction3OnCell(toGrid, tile.weapon2Id)) return PlaceRejectReason.WrongCellKind;
this.map.removeTile(fromGrid);
if (this._config.passiveSpawnKeepsDigCell(tile.weapon2Id)) {
this._convertPutToDig(toGrid);
}
this.map.placeTile(toGrid, { ...tile });
this._recompose();
this.emit(MergeEventFactory.tileMoved(fromGrid, toGrid));
return null;
}
/** 蟠桃树及 functiontype=3 产字均保持 Dig 格 */
private _keepsDigCell(tile: MapTileRecord): boolean {
return this._isFunction3Tile(tile) || this._config.passiveSpawnKeepsDigCell(tile.weapon2Id);
}
/**
* functiontype=3 产字可在未开挖格之间拖动,并复用已有合成规则。
*/
private _dropMapToMapKeepDig(fromGrid: number, toGrid: number, tile: MapTileRecord): PlaceRejectReason | null {
if (this._isFunction3Tile(tile)) {
return this._dropFunction3MapToMap(fromGrid, toGrid, tile);
}
const targetTile = this.map.getTile(toGrid);
if (targetTile) {
const merged = this._tryKeepDigMerge(fromGrid, toGrid, tile, targetTile);
if (merged === null) return null;
if (merged !== 'skip') return merged;
const swapped = this._tryKeepDigSwap(fromGrid, toGrid, tile, targetTile);
if (swapped !== 'skip') return swapped;
return PlaceRejectReason.WrongCellKind;
}
if (!this._canDropKeepDigOnCell(toGrid)) {
return PlaceRejectReason.WrongCellKind;
}
this.map.removeTile(fromGrid);
this._convertPutToDig(toGrid);
this.map.placeTile(toGrid, { ...tile });
this._recompose();
this.emit(MergeEventFactory.tileMoved(fromGrid, toGrid));
return null;
}
/** 空 Dig 或空 Put,且无蛛网 */
private _canDropKeepDigOnCell(gridIndex: number): boolean {
if (this._pathChecker?.isSpiderWebBlocked?.(gridIndex)) return false;
const cellKind = this._pathChecker?.getLiveCellKind?.(gridIndex)
?? this.map.getCellKind(gridIndex);
if (cellKind !== MapCellKind.Dig && cellKind !== MapCellKind.Put) return false;
return !this.map.hasTile(gridIndex);
}
/** 空 Put 被蟠桃树占用后转为 Dig */
private _convertPutToDig(gridIndex: number): void {
const kind = this.map.getCellKind(gridIndex);
if (kind !== MapCellKind.Put) return;
this.map.markCellAsDig(gridIndex);
}
/**
* 背包蟠桃树落到空 Dig/Put,或与地图蟠桃树合成/交换。
* @param slotIndex 背包槽
* @param gridIndex 目标格
* @param slot 背包字
*/
private _dropKeepDigBagToMap(slotIndex: number, gridIndex: number, slot: BagSlotRecord): PlaceRejectReason | null {
const tile = this._mapTileFromBagSlot(slot, TileSource.PassiveFunction3);
const targetTile = this.map.getTile(gridIndex);
if (targetTile) {
const merged = this._tryKeepDigBagMapMerge(slotIndex, gridIndex, tile, targetTile);
if (merged === null) return null;
if (merged !== 'skip') return merged;
const swapped = this._tryKeepDigBagMapSwap(slotIndex, gridIndex, tile, targetTile);
if (swapped !== 'skip') return swapped;
return PlaceRejectReason.WrongCellKind;
}
if (!this._canDropKeepDigOnCell(gridIndex)) return PlaceRejectReason.WrongCellKind;
this._convertPutToDig(gridIndex);
this.map.placeTile(gridIndex, tile);
this.bag.clearSlot(slotIndex);
this._recompose();
this.emit(MergeEventFactory.tilePlaced(gridIndex, slot.weapon2Id));
return null;
}
/**
* 地图蟠桃树拖回背包;源格保持 Dig。
* @param fromGrid 源格
* @param toSlot 背包槽
* @param tile 地图字
* @param bagSlot 目标槽现有字
*/
private _dropKeepDigMapToBag(fromGrid: number, toSlot: number, tile: MapTileRecord, bagSlot: BagSlotRecord | null): PlaceRejectReason | null {
if (bagSlot) {
const merged = this._tryKeepDigMapBagMerge(fromGrid, toSlot, tile, bagSlot);
if (merged === null) return null;
if (merged !== 'skip') return merged;
const swapped = this._tryKeepDigMapBagSwap(fromGrid, toSlot, tile, bagSlot);
if (swapped !== 'skip') return swapped;
return PlaceRejectReason.WrongCellKind;
}
this.map.removeTile(fromGrid);
this.bag.setSlot(toSlot, this._bagSlotFromMapTile(tile));
this._recompose();
this.emit(MergeEventFactory.tileReturned(fromGrid, toSlot));
return null;
}
/** 背包蟠桃树与地图蟠桃树同级合成 */
private _tryKeepDigBagMapMerge(slotIndex: number, gridIndex: number, sourceTile: MapTileRecord, targetTile: MapTileRecord): PlaceRejectReason | null | 'skip' {
if (!this._isSameKeepDigSource(sourceTile, targetTile)) return 'skip';
if (sourceTile.weapon2Id !== targetTile.weapon2Id) return 'skip';
const srcLevel = sourceTile.level ?? 1;
const dstLevel = targetTile.level ?? 1;
if (srcLevel !== dstLevel) return 'skip';
const max = this._keepDigMergeMax(sourceTile);
if (srcLevel >= max) return 'skip';
const newLevel = Math.min(max, this._upgrade.upgradeLevel(srcLevel));
this.bag.clearSlot(slotIndex);
this.map.placeTile(gridIndex, { ...targetTile, level: newLevel });
this._recompose();
this.emit(MergeEventFactory.mapUpgraded(gridIndex, targetTile.weapon1Id ?? 0, newLevel));
return null;
}
/** 背包与地图蟠桃树互换 */
private _tryKeepDigBagMapSwap(slotIndex: number, gridIndex: number, sourceTile: MapTileRecord, targetTile: MapTileRecord): PlaceRejectReason | null | 'skip' {
if (!this._isSameKeepDigSource(sourceTile, targetTile)) return 'skip';
this.map.placeTile(gridIndex, { ...sourceTile });
this.bag.setSlot(slotIndex, this._bagSlotFromMapTile(targetTile));
this._recompose();
this.emit(MergeEventFactory.tileMoved(gridIndex, gridIndex));
return null;
}
/** 地图蟠桃树与背包蟠桃树同级合成 */
private _tryKeepDigMapBagMerge(fromGrid: number, toSlot: number, mapTile: MapTileRecord, bagSlot: BagSlotRecord): PlaceRejectReason | null | 'skip' {
const bagTile: MapTileRecord = {
weapon2Id: bagSlot.weapon2Id,
source: TileSource.PassiveFunction3,
weapon1Id: bagSlot.weapon1Id,
level: bagSlot.level,
};
if (!this._isSameKeepDigSource(mapTile, bagTile)) return 'skip';
if (mapTile.weapon2Id !== bagSlot.weapon2Id) return 'skip';
const srcLevel = mapTile.level ?? 1;
const dstLevel = bagSlot.level ?? 1;
if (srcLevel !== dstLevel) return 'skip';
const max = this._keepDigMergeMax(mapTile);
if (srcLevel >= max) return 'skip';
const newLevel = Math.min(max, this._upgrade.upgradeLevel(srcLevel));
this.map.removeTile(fromGrid);
this.bag.setSlot(toSlot, {
weapon2Id: bagSlot.weapon2Id,
weapon1Id: bagSlot.weapon1Id,
level: newLevel,
});
this._recompose();
this.emit(MergeEventFactory.bagMerged(toSlot, bagSlot.weapon1Id ?? 0, newLevel));
return null;
}
/** 地图与背包蟠桃树互换;源格保持 Dig */
private _tryKeepDigMapBagSwap(fromGrid: number, toSlot: number, mapTile: MapTileRecord, bagSlot: BagSlotRecord): PlaceRejectReason | null | 'skip' {
const bagTile = this._mapTileFromBagSlot(bagSlot, TileSource.PassiveFunction3);
if (!this._isSameKeepDigSource(mapTile, bagTile)) return 'skip';
this.map.placeTile(fromGrid, bagTile);
this.bag.setSlot(toSlot, this._bagSlotFromMapTile(mapTile));
this._recompose();
this.emit(MergeEventFactory.tileMoved(fromGrid, fromGrid));
return null;
}
/** 背包内两棵蟠桃树同级合成 */
private _tryKeepDigBagBagMerge(fromSlot: number, toSlot: number, source: BagSlotRecord, target: BagSlotRecord): PlaceRejectReason | null | 'skip' {
if (source.weapon2Id !== target.weapon2Id) return 'skip';
const srcLevel = source.level ?? 1;
const dstLevel = target.level ?? 1;
if (srcLevel !== dstLevel) return 'skip';
const max = this._config.getKeepDigTileMaxLevel(source.weapon2Id);
if (srcLevel >= max) return 'skip';
const newLevel = Math.min(max, this._upgrade.upgradeLevel(srcLevel));
this.bag.setSlot(toSlot, {
weapon2Id: target.weapon2Id,
weapon1Id: target.weapon1Id,
level: newLevel,
});
this.bag.clearSlot(fromSlot);
this.emit(MergeEventFactory.bagMerged(toSlot, target.weapon1Id ?? 0, newLevel));
return null;
}
/** 蟠桃树合成等级上限 */
private _keepDigMergeMax(tile: MapTileRecord): number {
return this._isFunction3Tile(tile)
? this._config.getMaxWeaponLevel(tile.weapon1Id
?? this._config.getWeapon1ByGroupSequence([tile.weapon2Id])!)
: this._config.getKeepDigTileMaxLevel(tile.weapon2Id);
}
/** 保持 Dig 的同来源文字是否可合成 */
private _isSameKeepDigSource(sourceTile: MapTileRecord, targetTile: MapTileRecord): boolean {
const sourceFunction3 = this._isFunction3Tile(sourceTile);
const targetFunction3 = this._isFunction3Tile(targetTile);
if (sourceFunction3 || targetFunction3) return sourceFunction3 && targetFunction3;
return this._config.passiveSpawnKeepsDigCell(sourceTile.weapon2Id)
&& this._config.passiveSpawnKeepsDigCell(targetTile.weapon2Id);
}
/** 同来源保持 Dig 文字同级合成 */
private _tryKeepDigMerge(fromGrid: number, toGrid: number, sourceTile: MapTileRecord, targetTile: MapTileRecord): PlaceRejectReason | null | 'skip' {
if (!this._isSameKeepDigSource(sourceTile, targetTile)) return 'skip';
if (sourceTile.weapon2Id !== targetTile.weapon2Id) return 'skip';
const srcLevel = sourceTile.level ?? 1;
const dstLevel = targetTile.level ?? 1;
if (srcLevel !== dstLevel) return 'skip';
const max = this._isFunction3Tile(sourceTile)
? this._config.getMaxWeaponLevel(sourceTile.weapon1Id
?? this._config.getWeapon1ByGroupSequence([sourceTile.weapon2Id])!)
: this._config.getKeepDigTileMaxLevel(sourceTile.weapon2Id);
if (srcLevel >= max) return 'skip';
const newLevel = Math.min(max, this._upgrade.upgradeLevel(srcLevel));
this.map.removeTile(fromGrid);
this.map.placeTile(toGrid, {
...targetTile,
level: newLevel,
});
this._recompose();
this.emit(MergeEventFactory.mapUpgraded(toGrid, targetTile.weapon1Id ?? 0, newLevel));
return null;
}
/** 两棵蟠桃树不能合成时互换位置(均保持 Dig) */
private _tryKeepDigSwap(fromGrid: number, toGrid: number, sourceTile: MapTileRecord, targetTile: MapTileRecord): PlaceRejectReason | null | 'skip' {
if (!this._config.passiveSpawnKeepsDigCell(sourceTile.weapon2Id)) return 'skip';
if (!this._config.passiveSpawnKeepsDigCell(targetTile.weapon2Id)) return 'skip';
this.map.placeTile(toGrid, { ...sourceTile });
this.map.placeTile(fromGrid, { ...targetTile });
this.emit(MergeEventFactory.tileMoved(fromGrid, toGrid));
return null;
}
/** 地图上两个 type1 单字合成升级;type2 组合字不适用 */
private _tryMapToMapSingleMerge(
fromGrid: number,
toGrid: number,
sourceTile: MapTileRecord,
targetTile: MapTileRecord,
): PlaceRejectReason | null | 'skip' {
const srcW2Type = this._config.getWeapon2Type(sourceTile.weapon2Id) as Weapon2TileType;
const dstW2Type = this._config.getWeapon2Type(targetTile.weapon2Id) as Weapon2TileType;
if (srcW2Type !== Weapon2TileType.Single || dstW2Type !== Weapon2TileType.Single) {
return 'skip';
}
const srcW1 = this._resolveWeapon1Id(sourceTile);
const dstW1 = this._resolveWeapon1Id(targetTile);
if (srcW1 == null || dstW1 == null) return 'skip';
if (!this._config.isSingleCellWeapon1(srcW1)) return 'skip';
if (srcW1 !== dstW1) return 'skip';
const srcLevel = sourceTile.level ?? 1;
const dstLevel = targetTile.level ?? 1;
if (srcLevel !== dstLevel) return 'skip';
const max = this._config.getMaxWeaponLevel(srcW1);
// 满级:交给后续交换
if (srcLevel >= max) return 'skip';
if (this.isMergeUpgradeBlocked(fromGrid) || this.isMergeUpgradeBlocked(toGrid)) return 'skip';
const newLevel = Math.min(max, this._upgrade.upgradeLevel(srcLevel));
this.map.removeTile(fromGrid);
this.map.setCellKind(fromGrid, cellKindAfterReturnToBag());
this.map.placeTile(toGrid, {
...targetTile,
weapon1Id: srcW1,
level: newLevel,
});
const compose = this._findCompositionAt(toGrid);
if (compose) {
this._setCompositionLevel(compose, newLevel);
}
this._recompose();
this.emit(MergeEventFactory.mapUpgraded(toGrid, srcW1, newLevel));
return null;
}
/** 背包 type1 单字 → 地图同 Weapon1 同等级字块:合成升级 */
private _tryBagMapSingleMerge(
slotIndex: number,
gridIndex: number,
bagSlot: BagSlotRecord,
targetTile: MapTileRecord,
): PlaceRejectReason | null | 'skip' {
const srcW2Type = this._config.getWeapon2Type(bagSlot.weapon2Id) as Weapon2TileType;
const dstW2Type = this._config.getWeapon2Type(targetTile.weapon2Id) as Weapon2TileType;
if (srcW2Type !== Weapon2TileType.Single || dstW2Type !== Weapon2TileType.Single) {
return 'skip';
}
const srcW1 = bagSlot.weapon1Id ?? this._config.getWeapon1ByGroupSequence([bagSlot.weapon2Id]);
const dstW1 = this._resolveWeapon1Id(targetTile);
if (srcW1 == null || dstW1 == null) return 'skip';
if (!this._config.isSingleCellWeapon1(srcW1)) return 'skip';
if (srcW1 !== dstW1) return 'skip';
const srcLevel = bagSlot.level ?? 1;
const dstLevel = targetTile.level ?? 1;
if (srcLevel !== dstLevel) return 'skip';
const max = this._config.getMaxWeaponLevel(srcW1);
// 满级:交给后续交换
if (srcLevel >= max) return 'skip';
if (this.isMergeUpgradeBlocked(gridIndex)) return 'skip';
const newLevel = Math.min(max, this._upgrade.upgradeLevel(srcLevel));
this.bag.clearSlot(slotIndex);
this.map.placeTile(gridIndex, {
...targetTile,
weapon1Id: srcW1,
level: newLevel,
});
const compose = this._findCompositionAt(gridIndex);
if (compose) {
this._setCompositionLevel(compose, newLevel);
}
this._recompose();
this.emit(MergeEventFactory.mapUpgraded(gridIndex, srcW1, newLevel));
return null;
}
/** 背包 ↔ 地图:目标格已有字块且不能合成时交换(含已激活组合格,交换后 _recompose 拆组合) */
private _tryBagMapSwap(
slotIndex: number,
gridIndex: number,
bagSlot: BagSlotRecord,
mapTile: MapTileRecord,
): PlaceRejectReason | null | 'skip' {
if (!this._canSwapWeaponTile(bagSlot.weapon2Id, mapTile.weapon2Id)) return 'skip';
const cellKind = this.map.getCellKind(gridIndex);
if (cellKind !== MapCellKind.Put) return 'skip';
const mapW2Type = this._config.getWeapon2Type(mapTile.weapon2Id) as Weapon2TileType;
if (!canDragMapTile(mapTile, mapW2Type, false)) return 'skip';
this.map.placeTile(gridIndex, this._mapTileFromBagSlot(bagSlot, TileSource.BagRefresh));
this.bag.setSlot(slotIndex, this._bagSlotFromMapTile(mapTile));
this._recompose();
this.emit(MergeEventFactory.bagRefreshed(false));
this.emit(MergeEventFactory.tileMoved(gridIndex, gridIndex));
return null;
}
/** 地图 ↔ 地图:两格均有字块且不能合成时交换(含已激活组合格,交换后拆组合) */
private _tryMapToMapSwap(
fromGrid: number,
toGrid: number,
sourceTile: MapTileRecord,
targetTile: MapTileRecord,
): PlaceRejectReason | null | 'skip' {
if (!this._canSwapWeaponTile(sourceTile.weapon2Id, targetTile.weapon2Id)) return 'skip';
const fromKind = this.map.getCellKind(fromGrid);
const toKind = this.map.getCellKind(toGrid);
if (fromKind !== MapCellKind.Put || toKind !== MapCellKind.Put) return 'skip';
const srcW2Type = this._config.getWeapon2Type(sourceTile.weapon2Id) as Weapon2TileType;
const tgtW2Type = this._config.getWeapon2Type(targetTile.weapon2Id) as Weapon2TileType;
if (!canDragMapTile(sourceTile, srcW2Type, false)) return 'skip';
if (!canDragMapTile(targetTile, tgtW2Type, false)) return 'skip';
this.map.placeTile(toGrid, { ...sourceTile });
this.map.placeTile(fromGrid, { ...targetTile });
this._recompose();
this.emit(MergeEventFactory.tileMoved(fromGrid, toGrid));
return null;
}
/** 地图 ↔ 背包:背包槽已有字块且不能合成时交换(含已激活组合格,交换后拆组合) */
private _tryMapBagSwap(
fromGrid: number,
toSlot: number,
mapTile: MapTileRecord,
bagSlot: BagSlotRecord,
): PlaceRejectReason | null | 'skip' {
if (!this._canSwapWeaponTile(bagSlot.weapon2Id, mapTile.weapon2Id)) return 'skip';
const cellKind = this.map.getCellKind(fromGrid);
if (cellKind !== MapCellKind.Put) return 'skip';
this.map.placeTile(fromGrid, this._mapTileFromBagSlot(bagSlot, TileSource.BagRefresh));
this.bag.setSlot(toSlot, this._bagSlotFromMapTile(mapTile));
this._recompose();
this.emit(MergeEventFactory.bagRefreshed(false));
this.emit(MergeEventFactory.tileMoved(fromGrid, fromGrid));
return null;
}
/** 仅 type1/2 攻击字块可参与交换 */
private _canSwapWeaponTile(weapon2IdA: number, weapon2IdB: number): boolean {
const typeA = this._config.getWeapon2Type(weapon2IdA) as Weapon2TileType;
const typeB = this._config.getWeapon2Type(weapon2IdB) as Weapon2TileType;
const swappable = (t: Weapon2TileType) =>
t === Weapon2TileType.Single || t === Weapon2TileType.Combo;
return swappable(typeA) && swappable(typeB);
}
/** 背包槽 → 地图字块(含损毁与英雄绑定) */
private _mapTileFromBagSlot(slot: BagSlotRecord, source: TileSource): MapTileRecord {
const tile: MapTileRecord = {
weapon2Id: slot.weapon2Id,
source,
weapon1Id: slot.weapon1Id,
level: slot.level,
};
if (slot.ruined) tile.ruined = true;
if (slot.heroBind) tile.heroBind = this._cloneHeroBind(slot.heroBind);
return tile;
}
/** 地图字块 → 背包槽(含损毁与英雄绑定) */
private _bagSlotFromMapTile(tile: MapTileRecord): BagSlotRecord {
const slot: BagSlotRecord = {
weapon2Id: tile.weapon2Id,
weapon1Id: tile.weapon1Id,
level: tile.level,
};
if (tile.ruined) slot.ruined = true;
if (tile.heroBind) slot.heroBind = this._cloneHeroBind(tile.heroBind);
return slot;
}
private _resolveWeapon1Id(tile: MapTileRecord): number | null {
if (tile.weapon1Id != null && tile.weapon1Id > 0) {
return tile.weapon1Id;
}
return this._config.getWeapon1ByGroupSequence([tile.weapon2Id]);
}
/** 消耗组内一字升级前,快照原组合剩余字块格(须在改地图前调用) */
private _resolveSourceCompositionSiblings(fromMapGrid: number): number[] {
const compose = this._findCompositionAt(fromMapGrid);
if (!compose || compose.gridIndices.length <= 1) return [];
return compose.gridIndices.filter((idx) => idx !== fromMapGrid);
}
/** 原组合其余字块恢复为未组合(保留绑定,不含 weapon1Id) */
private _dissolveCompositionSiblings(gridIndices: readonly number[]): void {
for (const idx of gridIndices) {
const tile = this.map.getTile(idx);
if (!tile) continue;
const rec: MapTileRecord = {
weapon2Id: tile.weapon2Id,
source: tile.source,
level: tile.heroBind?.level ?? tile.level ?? 1,
};
if (tile.ruined) rec.ruined = true;
if (tile.heroBind) rec.heroBind = this._cloneHeroBind(tile.heroBind);
this.map.placeTile(idx, rec);
}
}
private _recompose(): void {
const layout = this.map.getWeapon2Layout();
const preserved = this._preservedCompositions(layout);
const used = createMembershipSet<number>();
for (const c of preserved) {
for (const idx of c.gridIndices) membershipAdd(used, idx);
}
const restLayout = new Map<number, number>();
layout.forEach((weapon2Id, gridIndex) => {
if (!used.has(gridIndex)) restLayout.set(gridIndex, weapon2Id);
});
const found = this._matcher.findCompositions(restLayout, this._coordOf);
this._compositions = preserved.concat(this._filterUniqueMultiHeroes(preserved, found));
const activeKeys = createMembershipSet<string>();
for (const c of this._compositions) {
const key = this._composeKey(c);
membershipAdd(activeKeys, key);
const level = this._inferCompositionLevel(c);
this._compositionLevels.set(key, level);
c.level = level;
if (this._isMultiCellComposition(c)) {
this._stampWinningHeroBind(c, level);
} else {
this._applyLevelToCompositionTiles(c, level);
}
}
this._compositionLevels.forEach((_level, key) => {
if (!activeKeys.has(key)) {
this._compositionLevels.delete(key);
}
});
this.emit(MergeEventFactory.composeChanged(this._compositions));
}
/**
* 多格神将:同 weapon1Id(Hero.id)本方最多 1 个;已有则新匹配保持散字不成第二将。
* @param preserved 已保留组合
* @param found 剩余布局新匹配
*/
private _filterUniqueMultiHeroes(preserved: readonly ComposedUnit[], found: readonly ComposedUnit[]): ComposedUnit[] {
const owned = createMembershipSet<number>();
for (let i = 0; i < preserved.length; i++) {
const c = preserved[i];
if (this._isMultiCellComposition(c)) membershipAdd(owned, c.weapon1Id);
}
const out: ComposedUnit[] = [];
for (let i = 0; i < found.length; i++) {
const c = found[i];
if (this._isMultiCellComposition(c)) {
if (owned.has(c.weapon1Id)) continue;
membershipAdd(owned, c.weapon1Id);
}
out.push(c);
}
return out;
}
/**
* 多字英雄一旦组成,格子未搬走就保持原组合。
* 母可同时属于王母/电母,全图重扫会把已有竖向电母拆掉再乱配。
*/
private _preservedCompositions(layout: Map<number, number>): ComposedUnit[] {
const out: ComposedUnit[] = [];
const used = createMembershipSet<number>();
for (const c of this._compositions) {
if (!this._isMultiCellComposition(c)) continue;
if (!this._compositionStillOnBoard(c, layout)) continue;
if (c.gridIndices.some(idx => used.has(idx))) continue;
out.push(c);
for (const idx of c.gridIndices) membershipAdd(used, idx);
}
return out;
}
private _compositionStillOnBoard(c: ComposedUnit, layout: Map<number, number>): boolean {
const entries: { weapon2Id: number; coord: GridCoord }[] = [];
for (const idx of c.gridIndices) {
const w2 = layout.get(idx);
if (w2 == null) return false;
const coord = this._coordOf(idx);
if (!coord) return false;
entries.push({ weapon2Id: w2, coord });
}
entries.sort((a, b) => (
c.direction === 'horizontal' ? a.coord.col - b.coord.col : a.coord.row - b.coord.row
));
const ids = entries.map((e) => e.weapon2Id);
const coords = entries.map((e) => e.coord);
if (this._config.getWeapon1ByGroupSequence(ids) !== c.weapon1Id) return false;
return isComposeLineContiguous(coords, c.direction);
}
/**
* 封神榜:是否可升级地图组合字武将 / 普通 type1 单字。
* @param gridIndex 地图格索引
* @param levelDelta functionpar 等级增量
* @returns null 表示可升级;否则为拒绝原因
*/
canApplyBattleBuffUnitLevelUp(gridIndex: number, levelDelta: number): PlaceRejectReason | null {
return this._evaluateBattleBuffUnitLevelUp(gridIndex, levelDelta);
}
/** 封神榜:是否可升级背包普通 type1 单字(type2 未合成散字不可) */
canApplyBattleBuffUnitLevelUpBag(slotIndex: number, levelDelta: number): PlaceRejectReason | null {
return this._evaluateBattleBuffUnitLevelUpBag(slotIndex, levelDelta);
}
/**
* 拖到字块类主动 buff 通用地图落点(封神榜/授仙箓/攻速符/千里眼等)。
* 可:多格组合字已合成武将、type1 普通单字;不可:type2 未合成英雄散字。
*/
canApplyBattleBuffSkillDropMap(gridIndex: number): PlaceRejectReason | null {
const compose = this._findCompositionAt(gridIndex);
if (compose && this._isMultiCellComposition(compose)) {
return null;
}
const target = this._resolveBattleBuffMapLevelTarget(gridIndex);
if ('reason' in target) return target.reason;
return null;
}
/** 拖到字块类主动 buff 通用背包落点(type1 普通单字;type2 未合成散字不可) */
canApplyBattleBuffSkillDropBag(slotIndex: number): PlaceRejectReason | null {
const target = this._resolveBattleBuffBagSingleTextTarget(slotIndex);
if ('reason' in target) return target.reason;
return null;
}
/**
* 封神榜:提升地图组合字武将 / 普通 type1 单字等级。
* @param gridIndex 地图格索引
* @param levelDelta functionpar 等级增量
* @returns null 表示成功
*/
applyBattleBuffUnitLevelUp(gridIndex: number, levelDelta: number): PlaceRejectReason | null {
const reason = this._evaluateBattleBuffUnitLevelUp(gridIndex, levelDelta);
if (reason != null) return reason;
const target = this._resolveBattleBuffMapLevelTarget(gridIndex);
if ('reason' in target) return target.reason;
if (target.mode === 'multiCell') {
return this._commitBattleBuffCompositionLevelUp(target.compose, levelDelta);
}
return this._commitBattleBuffMapSingleTextLevelDelta(gridIndex, target.weapon1Id, levelDelta);
}
/**
* 封神榜:提升背包普通 type1 单字等级。
* @param levelDelta functionpar 等级增量
*/
applyBattleBuffUnitLevelUpBag(slotIndex: number, levelDelta: number): PlaceRejectReason | null {
const reason = this._evaluateBattleBuffUnitLevelUpBag(slotIndex, levelDelta);
if (reason != null) return reason;
const target = this._resolveBattleBuffBagSingleTextTarget(slotIndex);
if ('reason' in target) return target.reason;
const currentLevel = target.slot.level ?? 1;
const newLevel = Math.min(target.maxLevel, currentLevel + levelDelta);
if (newLevel === currentLevel) return PlaceRejectReason.MaxLevel;
this.bag.setSlot(slotIndex, {
...target.slot,
weapon1Id: target.weapon1Id,
level: newLevel,
});
this.emit(MergeEventFactory.bagMerged(slotIndex, target.weapon1Id, newLevel));
return null;
}
/** 授仙箓:是否可作用于地图组合字武将 / 普通 type1 单字 */
canApplyBattleBuffRandomLevel(gridIndex: number): PlaceRejectReason | null {
return this._evaluateBattleBuffRandomLevel(gridIndex);
}
/** 授仙箓:是否可作用于背包普通 type1 单字(type2 未合成散字不可) */
canApplyBattleBuffRandomLevelBag(slotIndex: number): PlaceRejectReason | null {
return this._evaluateBattleBuffRandomLevelBag(slotIndex);
}
/**
* 授仙箓:掷骰升/降级增量(不写盘)。
* @param rollWan 0~9999
*/
rollBattleBuffRandomLevelMap(
gridIndex: number,
levelUpProbsWan: readonly number[],
rollWan: number,
): PlaceRejectReason | number {
const target = this._resolveBattleBuffMapLevelTarget(gridIndex);
if ('reason' in target) return target.reason;
return this._rollRandomLevelDelta(target.level, target.maxLevel, levelUpProbsWan, rollWan, true);
}
/**
* 授仙箓:掷骰升/降级增量(背包普通 type1 单字,不写盘)。
* @param rollWan 0~9999
*/
rollBattleBuffRandomLevelBag(
slotIndex: number,
levelUpProbsWan: readonly number[],
rollWan: number,
): PlaceRejectReason | number {
const target = this._resolveBattleBuffBagSingleTextTarget(slotIndex);
if ('reason' in target) return target.reason;
return this._rollRandomLevelDelta(target.level, target.maxLevel, levelUpProbsWan, rollWan, false);
}
/**
* 授仙箓:按已知增量写地图组合字武将 / 普通 type1 单字。
* @param levelDelta 等级变化(-1 / 0 / +1)
*/
applyBattleBuffRandomLevelMapDelta(gridIndex: number, levelDelta: number): PlaceRejectReason | number {
if (levelDelta === 0) return 0;
const target = this._resolveBattleBuffMapLevelTarget(gridIndex);
if ('reason' in target) return target.reason;
if (target.mode === 'multiCell') {
const reason = this._commitBattleBuffCompositionLevelDelta(target.compose, levelDelta);
if (reason != null) return reason;
return levelDelta;
}
const reason = this._commitBattleBuffMapSingleTextLevelDelta(gridIndex, target.weapon1Id, levelDelta);
if (reason != null) return reason;
return levelDelta;
}
/**
* 授仙箓:按已知增量写背包普通 type1 单字。
* @param levelDelta 等级变化(-1 / 0 / +1)
*/
applyBattleBuffRandomLevelBagDelta(slotIndex: number, levelDelta: number): PlaceRejectReason | number {
if (levelDelta === 0) return 0;
const target = this._resolveBattleBuffBagSingleTextTarget(slotIndex);
if ('reason' in target) return target.reason;
if (levelDelta < 0) return PlaceRejectReason.InvalidMerge;
const currentLevel = target.slot.level ?? 1;
const newLevel = Math.max(1, Math.min(target.maxLevel, currentLevel + levelDelta));
if (newLevel === currentLevel) return PlaceRejectReason.MaxLevel;
this.bag.setSlot(slotIndex, {
...target.slot,
weapon1Id: target.weapon1Id,
level: newLevel,
});
this.emit(MergeEventFactory.bagMerged(slotIndex, target.weapon1Id, newLevel));
return levelDelta;
}
/**
* 授仙箓:随机升/降级地图单字或已合成武将。
* @param rollWan 0~9999
*/
applyBattleBuffRandomLevel(
gridIndex: number,
levelUpProbsWan: readonly number[],
rollWan: number,
): PlaceRejectReason | number {
const rolled = this.rollBattleBuffRandomLevelMap(gridIndex, levelUpProbsWan, rollWan);
if (typeof rolled !== 'number') return rolled;
return this.applyBattleBuffRandomLevelMapDelta(gridIndex, rolled);
}
/**
* 授仙箓:随机升/降级背包普通单字。
* @param rollWan 0~9999
*/
applyBattleBuffRandomLevelBag(
slotIndex: number,
levelUpProbsWan: readonly number[],
rollWan: number,
): PlaceRejectReason | number {
const rolled = this.rollBattleBuffRandomLevelBag(slotIndex, levelUpProbsWan, rollWan);
if (typeof rolled !== 'number') return rolled;
return this.applyBattleBuffRandomLevelBagDelta(slotIndex, rolled);
}
/** 文昌笔:是否可改地图单字 */
canApplyBattleBuffRewriteGlyphMap(gridIndex: number): PlaceRejectReason | null {
return this._evaluateBattleBuffRewriteGlyphMap(gridIndex);
}
/** 文昌笔:是否可改背包单字 */
canApplyBattleBuffRewriteGlyphBag(slotIndex: number): PlaceRejectReason | null {
return this._evaluateBattleBuffRewriteGlyphBag(slotIndex);
}
/**
* 文昌笔:改写地图单字 weapon2Id(保留等级)。
* @returns null 表示成功
*/
applyBattleBuffRewriteGlyphMap(gridIndex: number, newWeapon2Id: number): PlaceRejectReason | null {
const target = this._resolveBattleBuffRewriteGlyphMapTarget(gridIndex);
if ('reason' in target) return target.reason;
if (newWeapon2Id <= 0) {
console.log('[MergeSession] 改字 newWeapon2Id 无效', newWeapon2Id);
return PlaceRejectReason.InvalidMerge;
}
if (target.weapon2Id === newWeapon2Id) return PlaceRejectReason.InvalidMerge;
const newType = this._config.getWeapon2Type(newWeapon2Id) as Weapon2TileType;
if (newType !== Weapon2TileType.Single && newType !== Weapon2TileType.Combo) {
console.log('[MergeSession] 改字结果须为单字/组合字', newWeapon2Id, newType);
return PlaceRejectReason.InvalidMerge;
}
this.map.placeTile(
gridIndex,
this._buildBattleBuffRewriteGlyphMapTile(target.tile, newWeapon2Id, target.level),
);
this._recompose();
this.emit(MergeEventFactory.glyphRewritten({ kind: 'map', gridIndex }, newWeapon2Id, target.level));
return null;
}
/**
* 文昌笔:改写背包单字 weapon2Id(保留等级)。
* @returns null 表示成功
*/
applyBattleBuffRewriteGlyphBag(slotIndex: number, newWeapon2Id: number): PlaceRejectReason | null {
const reason = this._evaluateBattleBuffRewriteGlyphBag(slotIndex);
if (reason != null) return reason;
if (newWeapon2Id <= 0) {
console.log('[MergeSession] 改字 newWeapon2Id 无效', newWeapon2Id);
return PlaceRejectReason.InvalidMerge;
}
const target = this._resolveBattleBuffRewriteBagTarget(slotIndex);
if ('reason' in target) return target.reason;
if (target.slot.weapon2Id === newWeapon2Id) return PlaceRejectReason.InvalidMerge;
const newType = this._config.getWeapon2Type(newWeapon2Id) as Weapon2TileType;
if (newType !== Weapon2TileType.Single && newType !== Weapon2TileType.Combo) {
console.log('[MergeSession] 改字结果须为单字/组合字', newWeapon2Id, newType);
return PlaceRejectReason.InvalidMerge;
}
const level = target.slot.level ?? 1;
this.bag.setSlot(
slotIndex,
this._buildBattleBuffRewriteGlyphBagSlot(target.slot, newWeapon2Id, level),
);
this.emit(MergeEventFactory.glyphRewritten({ kind: 'bag', slotIndex }, newWeapon2Id, level));
return null;
}
/** 校验主动 buff 回收目标(地图单字/组合字/单格武将) */
canApplyBattleBuffRecycleMap(gridIndex: number): PlaceRejectReason | null {
return this._evaluateBattleBuffRecycleMap(gridIndex);
}
/**
* 主动 buff:回收地图字块/单格武将。
* @returns null 表示成功
*/
applyBattleBuffRecycleMap(gridIndex: number): PlaceRejectReason | null {
const reason = this._evaluateBattleBuffRecycleMap(gridIndex);
if (reason != null) return reason;
this.map.removeTile(gridIndex);
this.map.setCellKind(gridIndex, cellKindAfterReturnToBag());
this._recompose();
this.emit(MergeEventFactory.composeChanged(this._compositions));
return null;
}
/**
* 技能损毁:移除单格字块(不限 Weapon2 类型;多格组合免疫)。
* @returns null 表示成功
*/
applySkillDestroyMap(gridIndex: number): PlaceRejectReason | null {
const tile = this.map.getTile(gridIndex);
if (!tile) return PlaceRejectReason.InvalidMerge;
const compose = this._findCompositionAt(gridIndex);
if (compose && this._isMultiCellComposition(compose)) {
return PlaceRejectReason.InvalidMerge;
}
this.map.removeTile(gridIndex);
this.map.setCellKind(gridIndex, cellKindAfterReturnToBag());
this._recompose();
this.emit(MergeEventFactory.composeChanged(this._compositions));
return null;
}
/** 校验主动 buff 回收背包字块 */
canApplyBattleBuffRecycleBag(slotIndex: number): PlaceRejectReason | null {
return this._evaluateBattleBuffRecycleBag(slotIndex);
}
/**
* 主动 buff:回收背包字块。
* @returns null 表示成功
*/
applyBattleBuffRecycleBag(slotIndex: number): PlaceRejectReason | null {
const reason = this._evaluateBattleBuffRecycleBag(slotIndex);
if (reason != null) return reason;
this.bag.clearSlot(slotIndex);
this.emit(MergeEventFactory.bagRefreshed(false));
return null;
}
/** 校验妙手神偷目标(Put 区未合成武将散字 Combo 或普通单字 Single;多格合成武将/武器字/铲/格不可偷) */
canApplyStealGlyphMap(gridIndex: number): PlaceRejectReason | null {
return this._evaluateStealGlyphMap(gridIndex);
}
/** 指定格是否为多格合成武将 */
isMultiCellCompositionAt(gridIndex: number): boolean {
const compose = this._findCompositionAt(gridIndex);
return compose != null && this._isMultiCellComposition(compose);
}
/** 指定格是否为组合字(Weapon2.type=2,含未成将散字) */
isComboGlyphAt(gridIndex: number): boolean {
const tile = this.map.getTile(gridIndex);
if (!tile) return false;
return Number(this._config.getWeapon2Type(tile.weapon2Id)) === Weapon2TileType.Combo;
}
/**
* 妙手神偷:从地图提取未合成武将散字或普通单字(保留 tile 副本);只清字块,不改格子语义。
* @returns tile 或拒绝原因
*/
extractMapTileForSteal(gridIndex: number): { tile: MapTileRecord } | { reason: PlaceRejectReason } {
const reason = this._evaluateStealGlyphMap(gridIndex);
if (reason != null) return { reason };
const tile = this.map.getTile(gridIndex);
if (!tile) return { reason: PlaceRejectReason.InvalidMerge };
const copy: MapTileRecord = { ...tile };
this.map.removeTile(gridIndex);
this._recompose();
this.emit(MergeEventFactory.composeChanged(this._compositions));
return { tile: copy };
}
/**
* 妙手神偷:偷取字块落入己方空 Put 格。
* @returns null 表示成功
*/
grantStolenTileOnPutGrid(gridIndex: number, tile: MapTileRecord): PlaceRejectReason | null {
if (!this._prepActive) return PlaceRejectReason.NotPrepPhase;
if (this.map.getCellKind(gridIndex) !== MapCellKind.Put) return PlaceRejectReason.WrongCellKind;
if (this.map.hasTile(gridIndex)) return PlaceRejectReason.CellOccupied;
this.map.placeTile(gridIndex, { ...tile });
this._recompose();
this.emit(MergeEventFactory.tilePlaced(gridIndex, tile.weapon2Id));
return null;
}
/**
* 妙手神偷:偷取字块落入己方背包空槽。
* @returns null 表示成功
*/
grantStolenTileInBagSlot(slotIndex: number, tile: MapTileRecord): PlaceRejectReason | null {
if (!this._prepActive) return PlaceRejectReason.NotPrepPhase;
if (slotIndex < 0 || slotIndex >= this.bag.slotCount) return PlaceRejectReason.InvalidMerge;
if (this.bag.getSlot(slotIndex)) return PlaceRejectReason.BagFull;
this.bag.setSlot(slotIndex, this._bagSlotFromMapTile(tile));
this.emit(MergeEventFactory.bagRefreshed(false));
return null;
}
/** 校验封神榜升级目标(地图组合字武将 / 普通 type1 单字) */
private _evaluateBattleBuffUnitLevelUp(gridIndex: number, levelDelta: number): PlaceRejectReason | null {
if (levelDelta <= 0) {
console.log('[MergeSession] buff 升级 levelDelta 无效', levelDelta);
return PlaceRejectReason.InvalidMerge;
}
const target = this._resolveBattleBuffMapLevelTarget(gridIndex);
if ('reason' in target) return target.reason;
if (target.level >= target.maxLevel) return PlaceRejectReason.MaxLevel;
if (target.level + levelDelta <= target.level) return PlaceRejectReason.MaxLevel;
return null;
}
/** 校验封神榜升级目标(背包普通 type1 单字) */
private _evaluateBattleBuffUnitLevelUpBag(slotIndex: number, levelDelta: number): PlaceRejectReason | null {
if (levelDelta <= 0) {
console.log('[MergeSession] buff 升级 levelDelta 无效', levelDelta);
return PlaceRejectReason.InvalidMerge;
}
const target = this._resolveBattleBuffBagSingleTextTarget(slotIndex);
if ('reason' in target) return target.reason;
if (target.level >= target.maxLevel) return PlaceRejectReason.MaxLevel;
if (target.level + levelDelta <= target.level) return PlaceRejectReason.MaxLevel;
return null;
}
/** 已合成多格武将:整组升级 */
private _commitBattleBuffCompositionLevelUp(compose: ComposedUnit, levelDelta: number): PlaceRejectReason | null {
return this._commitBattleBuffCompositionLevelDelta(compose, levelDelta);
}
/** 已合成多格武将:整组升/降级 */
private _commitBattleBuffCompositionLevelDelta(compose: ComposedUnit, levelDelta: number): PlaceRejectReason | null {
const composeLevel = this._maxTileLevelInCompose(compose);
const max = this._config.getMaxWeaponLevel(compose.weapon1Id);
const newLevel = Math.max(1, Math.min(max, composeLevel + levelDelta));
if (newLevel === composeLevel) return PlaceRejectReason.MaxLevel;
this._applyLevelToCompositionTiles(compose, newLevel);
this._setCompositionLevel(compose, newLevel);
compose.level = newLevel;
this.emit(MergeEventFactory.mapUpgraded(compose.gridIndices[0], compose.weapon1Id, newLevel));
return null;
}
/** 授仙箓目标校验(地图组合字武将 / 普通 type1 单字) */
private _evaluateBattleBuffRandomLevel(gridIndex: number): PlaceRejectReason | null {
const target = this._resolveBattleBuffMapLevelTarget(gridIndex);
if ('reason' in target) return target.reason;
if (target.level >= target.maxLevel) return PlaceRejectReason.MaxLevel;
return null;
}
/** 授仙箓目标校验(背包普通 type1 单字) */
private _evaluateBattleBuffRandomLevelBag(slotIndex: number): PlaceRejectReason | null {
const target = this._resolveBattleBuffBagSingleTextTarget(slotIndex);
if ('reason' in target) return target.reason;
if (target.level <= 1 && target.level >= target.maxLevel) {
return PlaceRejectReason.MaxLevel;
}
return null;
}
/** 封神榜 / 授仙箓地图目标:多格合成武将或普通 type1 单字(type2 未合成散字不可) */
private _resolveBattleBuffMapLevelTarget(gridIndex: number):
| { mode: 'multiCell'; compose: ComposedUnit; level: number; maxLevel: number }
| { mode: 'singleText'; weapon1Id: number; level: number; maxLevel: number }
| { reason: PlaceRejectReason } {
const tile = this.map.getTile(gridIndex);
if (!tile) return { reason: PlaceRejectReason.InvalidMerge };
const compose = this._findCompositionAt(gridIndex);
if (compose && this._isMultiCellComposition(compose)) {
return {
mode: 'multiCell',
compose,
level: this._maxTileLevelInCompose(compose),
maxLevel: this._config.getMaxWeaponLevel(compose.weapon1Id),
};
}
const w2Type = this._config.getWeapon2Type(tile.weapon2Id) as Weapon2TileType;
if (w2Type === Weapon2TileType.Combo) {
return { reason: PlaceRejectReason.InvalidMerge };
}
if (w2Type !== Weapon2TileType.Single) {
return { reason: PlaceRejectReason.InvalidMerge };
}
const w1 = this._resolveWeapon1Id(tile);
if (w1 == null) return { reason: PlaceRejectReason.InvalidMerge };
return {
mode: 'singleText',
weapon1Id: w1,
level: tile.level ?? 1,
maxLevel: this._config.getMaxWeaponLevel(w1),
};
}
/** 背包 type1 单字 buff 目标(封神榜 / 授仙箓;type2 未合成散字不可) */
private _resolveBattleBuffBagSingleTextTarget(slotIndex: number):
| { level: number; maxLevel: number; weapon1Id: number; slot: BagSlotRecord }
| { reason: PlaceRejectReason } {
const slot = this.bag.getSlot(slotIndex);
if (!slot) return { reason: PlaceRejectReason.InvalidMerge };
const w2Type = this._config.getWeapon2Type(slot.weapon2Id) as Weapon2TileType;
if (w2Type === Weapon2TileType.Combo) {
return { reason: PlaceRejectReason.InvalidMerge };
}
if (w2Type !== Weapon2TileType.Single) {
return { reason: PlaceRejectReason.InvalidMerge };
}
const w1 = slot.weapon1Id ?? this._config.getWeapon1ByGroupSequence([slot.weapon2Id]);
if (w1 == null) {
return { reason: PlaceRejectReason.InvalidMerge };
}
return {
level: slot.level ?? 1,
maxLevel: this._config.getMaxWeaponLevel(w1),
weapon1Id: w1,
slot,
};
}
/** 普通 type1 单字:写地图等级 */
private _commitBattleBuffMapSingleTextLevelDelta(
gridIndex: number,
weapon1Id: number,
levelDelta: number,
): PlaceRejectReason | null {
const tile = this.map.getTile(gridIndex);
if (!tile) return PlaceRejectReason.InvalidMerge;
const currentLevel = tile.level ?? 1;
const max = this._config.getMaxWeaponLevel(weapon1Id);
const newLevel = Math.max(1, Math.min(max, currentLevel + levelDelta));
if (newLevel === currentLevel) return PlaceRejectReason.MaxLevel;
this.map.placeTile(gridIndex, { ...tile, weapon1Id, level: newLevel });
const singleCompose = this._findCompositionAt(gridIndex);
if (singleCompose) {
this._setCompositionLevel(singleCompose, newLevel);
}
this.emit(MergeEventFactory.mapUpgraded(gridIndex, weapon1Id, newLevel));
return null;
}
/** 文昌笔:地图单字 / 组合字 / 单格武将目标校验 */
private _evaluateBattleBuffRewriteGlyphMap(gridIndex: number): PlaceRejectReason | null {
const target = this._resolveBattleBuffRewriteGlyphMapTarget(gridIndex);
if ('reason' in target) return target.reason;
return null;
}
/** 文昌笔:背包单字 / 组合字 / 单格武将目标校验 */
private _evaluateBattleBuffRewriteGlyphBag(slotIndex: number): PlaceRejectReason | null {
const target = this._resolveBattleBuffRewriteBagTarget(slotIndex);
if ('reason' in target) return target.reason;
return null;
}
/**
* 文昌笔地图目标:type1 单字 / 组合字 / 单格武将。
* 多格已合成武将(compose 占 ≥2 格且非 type1 单字)不可用。
*/
private _resolveBattleBuffRewriteGlyphMapTarget(gridIndex: number):
| { tile: MapTileRecord; weapon2Id: number; level: number }
| { reason: PlaceRejectReason } {
const tile = this.map.getTile(gridIndex);
if (!tile) return { reason: PlaceRejectReason.InvalidMerge };
const compose = this._findCompositionAt(gridIndex);
if (compose && this._isMultiCellComposition(compose)) {
return { reason: PlaceRejectReason.InvalidMerge };
}
const w2Type = this._config.getWeapon2Type(tile.weapon2Id) as Weapon2TileType;
if (w2Type !== Weapon2TileType.Single && w2Type !== Weapon2TileType.Combo) {
return { reason: PlaceRejectReason.InvalidMerge };
}
return {
tile,
weapon2Id: tile.weapon2Id,
level: tile.level ?? 1,
};
}
/**
* 文昌笔背包目标:type1 单字 / 组合字 / 单格武将。
*/
private _resolveBattleBuffRewriteBagTarget(slotIndex: number):
| { slot: BagSlotRecord; weapon2Id: number }
| { reason: PlaceRejectReason } {
const slot = this.bag.getSlot(slotIndex);
if (!slot) return { reason: PlaceRejectReason.InvalidMerge };
const w2Type = this._config.getWeapon2Type(slot.weapon2Id) as Weapon2TileType;
if (w2Type !== Weapon2TileType.Single && w2Type !== Weapon2TileType.Combo) {
return { reason: PlaceRejectReason.InvalidMerge };
}
return { slot, weapon2Id: slot.weapon2Id };
}
/** 改字后地图 tile(保留 source/等级/绑定;单格武将写入 weapon1Id) */
private _buildBattleBuffRewriteGlyphMapTile(
prev: MapTileRecord,
newWeapon2Id: number,
level: number,
): MapTileRecord {
const next: MapTileRecord = {
weapon2Id: newWeapon2Id,
level,
source: prev.source,
};
if (prev.ruined) next.ruined = true;
if (prev.heroBind) next.heroBind = this._cloneHeroBind(prev.heroBind);
const w1 = this._config.getWeapon1ByGroupSequence([newWeapon2Id]);
if (w1 != null && this._config.isSingleCellWeapon1(w1)) {
next.weapon1Id = w1;
}
return next;
}
/** 改字后背包槽(保留等级/绑定;单格武将写入 weapon1Id) */
private _buildBattleBuffRewriteGlyphBagSlot(
prev: BagSlotRecord,
newWeapon2Id: number,
level: number,
): BagSlotRecord {
const next: BagSlotRecord = {
weapon2Id: newWeapon2Id,
level,
};
if (prev.ruined) next.ruined = true;
if (prev.heroBind) next.heroBind = this._cloneHeroBind(prev.heroBind);
const w1 = this._config.getWeapon1ByGroupSequence([newWeapon2Id]);
if (w1 != null && this._config.isSingleCellWeapon1(w1)) {
next.weapon1Id = w1;
}
return next;
}
/** 授仙箓掷骰结果;背包路径 allowLevelDown=false 时不降级 */
private _rollRandomLevelDelta(
currentLevel: number,
maxLevel: number,
levelUpProbsWan: readonly number[],
rollWan: number,
allowLevelDown: boolean,
): number {
const level = Math.max(1, Math.floor(currentLevel) || 1);
const max = Math.max(1, Math.floor(maxLevel) || 1);
const roll = Math.max(0, Math.min(9999, Math.floor(rollWan) || 0));
if (level < max) {
const prob = levelUpProbsWan[level - 1] ?? 0;
if (roll < prob) return 1;
}
if (allowLevelDown && level > 1) return -1;
return 0;
}
private _findCompositionAt(gridIndex: number): ComposedUnit | null {
return this._compositions.find((c) => c.gridIndices.includes(gridIndex)) ?? null;
}
private _composeKey(c: ComposedUnit): string {
return `${c.weapon1Id}_${c.gridIndices.join(',')}`;
}
private _getCompositionLevel(c: ComposedUnit): number {
return this._compositionLevels.get(this._composeKey(c)) ?? c.level;
}
private _setCompositionLevel(c: ComposedUnit, level: number): void {
this._compositionLevels.set(this._composeKey(c), level);
c.level = level;
}
private _inferCompositionLevel(c: ComposedUnit): number {
const cap = this._config.getMaxWeaponLevel(c.weapon1Id);
const lv = this._maxTileLevelInCompose(c);
return lv > cap ? cap : lv;
}
/**
* 把绑定快照写到组合内每个字上(组合英雄与基础单字)。
* @param compose 合成单位
* @param bind 英雄状态
*/
writeCompositionHeroBind(compose: ComposedUnit, bind: GlyphHeroBind): void {
if (compose.gridIndices.length <= 0) return;
const cap = this._config.getMaxWeaponLevel(compose.weapon1Id);
const level = Math.max(1, Math.min(cap, bind.level));
this._stampHeroBindToTiles(compose, { ...this._cloneHeroBind(bind), level }, level);
}
/**
* 读取组合内等级最高的字绑定。
* @param compose 合成单位
*/
readCompositionHeroBind(compose: ComposedUnit): GlyphHeroBind | null {
const win = this._pickWinningHeroBind(compose);
return win ? this._cloneHeroBind(win) : null;
}
/** 重组时按两字绑定英雄最高等级继承,并写回所有字 */
private _stampWinningHeroBind(c: ComposedUnit, level: number): void {
const win = this._pickWinningHeroBind(c);
this._stampHeroBindToTiles(c, win ? { ...this._cloneHeroBind(win), level } : null, level);
}
/**
* 把绑定写到组合内每个字,并统一等级。
* @param compose 组合
* @param bind 绑定;空则只写等级
* @param level 统一等级
*/
private _stampHeroBindToTiles(compose: ComposedUnit, bind: GlyphHeroBind | null, level: number): void {
for (let i = 0; i < compose.gridIndices.length; i++) {
const idx = compose.gridIndices[i];
const tile = this.map.getTile(idx);
if (!tile) continue;
const rec: MapTileRecord = { ...tile, level };
if (bind) rec.heroBind = this._cloneHeroBind(bind);
this.map.placeTile(idx, rec);
}
}
/** 两个字里绑定英雄等级更高的一方;同级比经验 */
private _pickWinningHeroBind(c: ComposedUnit): GlyphHeroBind | null {
let best: GlyphHeroBind | null = null;
for (let i = 0; i < c.gridIndices.length; i++) {
const bind = this.map.getTile(c.gridIndices[i])?.heroBind;
if (!bind) continue;
if (!best || bind.level > best.level || (bind.level === best.level && bind.exp > best.exp)) {
best = bind;
}
}
return best;
}
/** 拷贝绑定快照 */
private _cloneHeroBind(bind: GlyphHeroBind): GlyphHeroBind {
const aspd: GlyphHeroBuff[] = [];
for (let i = 0; i < bind.aspd.length; i++) aspd.push({ ...bind.aspd[i] });
const range: GlyphHeroBuff[] = [];
for (let i = 0; i < bind.range.length; i++) range.push({ ...bind.range[i] });
return {
weapon1Id: bind.weapon1Id,
level: bind.level,
exp: bind.exp,
aspd,
range,
};
}
private emit(event: MergeEvent): void {
if (this._emitSuppressed) return;
const specific = this._listeners.get(event.type);
if (specific) membershipForEach(specific, fn => fn(event));
const all = this._listeners.get(MERGE_EVENT_ALL);
if (all) membershipForEach(all, fn => fn(event));
if (event.type === MergeEventType.PlaceRejected) return;
}
reject(reason: PlaceRejectReason): void {
this.emit(MergeEventFactory.placeRejected(reason));
}
/** 校验地图回收目标(单字/组合字/单格武将;多格武将不可回收) */
private _evaluateBattleBuffRecycleMap(gridIndex: number): PlaceRejectReason | null {
const tile = this.map.getTile(gridIndex);
if (!tile) return PlaceRejectReason.InvalidMerge;
const compose = this._findCompositionAt(gridIndex);
if (compose && this._isMultiCellComposition(compose)) {
return PlaceRejectReason.InvalidMerge;
}
const w2Type = this._config.getWeapon2Type(tile.weapon2Id) as Weapon2TileType;
if (w2Type === Weapon2TileType.Single || w2Type === Weapon2TileType.Combo) {
return null;
}
return PlaceRejectReason.InvalidMerge;
}
/** 校验妙手神偷:Put 区未合成武将散字(Combo)或普通单字(Single);多格合成武将/武器字/铲/格/背包不可偷 */
private _evaluateStealGlyphMap(gridIndex: number): PlaceRejectReason | null {
const tile = this.map.getTile(gridIndex);
if (!tile) return PlaceRejectReason.InvalidMerge;
if (this.map.getCellKind(gridIndex) !== MapCellKind.Put) {
return PlaceRejectReason.WrongCellKind;
}
const compose = this._findCompositionAt(gridIndex);
if (compose && this._isMultiCellComposition(compose)) {
return PlaceRejectReason.InvalidMerge;
}
const w2Type = this._config.getWeapon2Type(tile.weapon2Id) as Weapon2TileType;
if (w2Type === Weapon2TileType.Single || w2Type === Weapon2TileType.Combo) {
return null;
}
return PlaceRejectReason.InvalidMerge;
}
/** 校验背包回收目标(单字/组合字) */
private _evaluateBattleBuffRecycleBag(slotIndex: number): PlaceRejectReason | null {
const slot = this.bag.getSlot(slotIndex);
if (!slot) return PlaceRejectReason.InvalidMerge;
const w2Type = this._config.getWeapon2Type(slot.weapon2Id) as Weapon2TileType;
if (w2Type === Weapon2TileType.Single || w2Type === Weapon2TileType.Combo) {
return null;
}
return PlaceRejectReason.InvalidMerge;
}
/**
* 技能封地:吞掉单格字块并使格不可再放置(组合武将与组合字免疫)。
* @returns 是否成功改变地块
*/
skillSwallowDeployTile(gridIndex: number): boolean {
return this.skillSealPutToDig(gridIndex);
}
/**
* 千里眼:可放置格改为可开挖;有单字则移除(组合武将与组合字免疫)。
* @returns 是否成功改格
*/
skillSealPutToDig(gridIndex: number): boolean {
const comp = this._findCompositionAt(gridIndex);
if (comp && this._isMultiCellComposition(comp)) {
console.log('[MergeSession] 组合武将免疫封格', gridIndex);
return false;
}
if (this.isComboGlyphAt(gridIndex)) {
console.log('[MergeSession] 组合字免疫封格', gridIndex);
return false;
}
const kind = this.map.getCellKind(gridIndex);
if (kind !== MapCellKind.Put) {
console.log('[MergeSession] 封格失败:不是可放置格', gridIndex, kind);
return false;
}
if (this.map.hasTile(gridIndex)) {
this.map.removeTile(gridIndex);
}
this.map.setPrepType3(gridIndex, false);
this.map.setCellKind(gridIndex, MapCellKind.Dig);
this._recompose();
this.emit(MergeEventFactory.composeChanged(this._compositions));
return true;
}
/**
* 调整指定格合成体等级(仅单格;delta 可负)。
* @param allowFloorAtOne true 时低于 1 钳到 1;false 时拒绝
* @returns 是否实际变更
*/
adjustCompositionLevelAtGrid(gridIndex: number, delta: number, allowFloorAtOne = true): boolean {
const comp = this._findCompositionAt(gridIndex);
if (!comp) return false;
const cur = this._getCompositionLevel(comp);
const next = cur + delta;
if (next < 1 && !allowFloorAtOne) return false;
return this.setCompositionLevelAtGrid(gridIndex, next);
}
/**
* 将指定格合成体设为绝对等级(仅单格,钳制在 1~上限)。
* @returns 是否实际变更
*/
setCompositionLevelAtGrid(gridIndex: number, level: number): boolean {
const comp = this._findCompositionAt(gridIndex);
if (!comp || comp.gridIndices.length > 1) return false;
const idx = comp.gridIndices[0];
const tile = this.map.getTile(idx);
if (!tile) return false;
const cur = this._getCompositionLevel(comp);
const max = this._config.getMaxWeaponLevel(comp.weapon1Id);
const newLevel = Math.max(1, Math.min(max, Math.floor(Number(level) || 1)));
if (newLevel === cur) return false;
this.map.placeTile(idx, { ...tile, level: newLevel });
this._setCompositionLevel(comp, newLevel);
comp.level = newLevel;
this.emit(MergeEventFactory.mapUpgraded(idx, comp.weapon1Id, newLevel));
return true;
}
/** 战斗快照:恢复背包/地图/组合态(不触发拖放校验) */
restoreBoardState(
data: {
prepActive: boolean;
bagSlots: ReadonlyArray<BagSlotRecord | null>;
mapCells: ReadonlyArray<{ gridIndex: number; cell: import('../board/MapTileBoard').MapCellSnapshot }>;
mapTiles: ReadonlyArray<{ gridIndex: number; tile: MapTileRecord }>;
compositions: readonly ComposedUnit[];
},
opts?: { silent?: boolean },
): void {
this._prepActive = data.prepActive;
for (let i = 0; i < this.bag.slotCount; i++) {
const slot = data.bagSlots[i] ?? null;
this.bag.setSlot(i, slot ? { ...slot } : null);
}
this.map.restoreBoard(data.mapCells, data.mapTiles);
this._compositions = data.compositions.map(c => ({
weapon1Id: c.weapon1Id,
level: c.level,
gridIndices: [...c.gridIndices],
direction: c.direction,
}));
this._compositionLevels.clear();
for (const c of this._compositions) {
this._compositionLevels.set(this._composeKey(c), c.level);
}
if (!opts?.silent) {
this.emit(MergeEventFactory.bagRefreshed(false));
this.emit(MergeEventFactory.composeChanged(this._compositions));
}
}
}
function isComposeLineContiguous(
coords: GridCoord[],
direction: 'horizontal' | 'vertical',
): boolean {
for (let i = 1; i < coords.length; i++) {
if (direction === 'horizontal') {
if (coords[i].col !== coords[i - 1].col + 1) return false;
if (coords[i].row !== coords[i - 1].row) return false;
} else {
if (coords[i].row !== coords[i - 1].row + 1) return false;
if (coords[i].col !== coords[i - 1].col) return false;
}
}
return true;
}