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

7311 lines
302 KiB

import {
BlockInputEvents,
Director,
EventTouch,
director,
isValid,
Layout,
Node,
Rect,
tween,
Tween,
UIOpacity,
UITransform,
Vec2,
Vec3,
} from 'cc';
import {
MERGE_EVENT_ALL,
MergeEventType,
PlaceRejectReason,
MapCellKind,
Weapon2TileType,
canDragMapTile,
canDragPrepType3Cell,
isElementGlyphType,
PREP_TYPE3_MOVE_WINDOW_MS,
rejectReasonToMessage,
type ComposedUnit,
type DragSource,
type DropTarget,
type MergeEvent,
type MapTileRecord,
TileSource,
} from 'db://assets/mx/module/merge';
import { BattlePhase } from 'db://assets/mx/module/battle';
import { AbilitySetComp } from 'db://assets/mx/module/battle/ecs/Components';
import { GEvent } from 'db://assets/mx/module/event/GEvent';
import { createMembershipSet, membershipAdd, membershipDedupeArray, membershipToArray, mapKeysToArray, type MembershipSet, type ReadonlyMembershipSet } from '../../../mx/tools/MembershipSet';
import type { ProjectBattleHandle } from '../runtime/BattleRuntime';
import { getBattleGameGlobalAttackSpeedHost } from '../combat/BattleGameGlobalAttackSpeedHost';
import { getBattleGameGlobalAttackRangeHost, BattleGameGlobalAttackRangeHost } from '../combat/BattleGameGlobalAttackRangeHost';
import type { MapManager } from '../map/legacy/MapManager';
import type { MapGrid } from '../map/legacy/MapGrid';
import { GBundle, UIID } from '../../game/ConfigRes';
import { TableNames } from '../../game/ConfigTableData';
import type { BattleWeaponDesc1PanelData } from '../../ui/BattleWeaponDesc1/UIBattleWeaponDesc1';
import type { BattleWeaponDescHeroPanelData } from '../../ui/BattleWeaponDescHero/UIBattleWeaponDescHero';
import { BattleBagSession } from '../bag/BattleBagSession';
import { DeploySync } from '../runtime/DeploySync';
import { composeKey } from '../runtime/ComposeUnitKey';
import { findEntityIdByGridIndex } from '../runtime/DeployEntityQuery';
import { getTowerLoadoutService, resolveHeroBattleWeaponId } from '../combat/TowerLoadoutService';
import { getTowerLevelDownHost } from '../combat/TowerLevelDownHost';
import { getTowerRuinHost } from '../combat/TowerRuinHost';
import { SkillAtkBuffBoardViewBind } from '../combat/SkillAtkBuffBoardBridge';
import type { SkillController } from '../skill/SkillController';
import {
applyBattleBuffLevelUpViaExpFill,
applyBattleBuffRandomLevelWithExpFill,
canApplyBattleBuffLevelUpViaExpFill,
shouldBattleBuffLevelUpViaExpFill,
} from '../combat/BattleBuffUnitLevelUpApply';
import { AI_PLACE_CAMP, aiPlaceCampForPlayer } from '../ai/runtime/AiCampArea';
import type { CharmTarget } from '../mechanism/MechanismServices';
import { HeroActorHost } from '../heros/HeroActorHost';
import type { HeroSkin } from './HeroSkin';
import { isHeroActorWeapon1 } from '../heros/HeroActorRes';
import { GRID_WEAPON2_ID, PEACH_TREE_WEAPON2_ID } from '../bag/BagPrepConfig';
import { WeaponComposeConfigProvider } from '../bag/WeaponComposeConfigProvider';
import { textDisplayName } from '../config/BattleHeroTable';
import { TextGrid, type TextGridData, type TextGridEntityData, type TextGridLocation } from './TextGrid';
import { TextListCellState, TextListLandHost } from './TextListLandHost';
import { DeviceProfile } from './DeviceProfile';
import { ClientErrorReporter } from '../../../mx/tools/ClientErrorReporter';
import { buildTextGridEntityData, TowerQueryService } from '../combat/TowerQueryService';
import type { TowerRuntimeSnapshot } from '../combat/TowerQueryTypes';
import { BagSlotEntranceAnimator } from './BagSlotEntranceAnimator';
import { BagToMapPlaceAnimator } from './BagToMapPlaceAnimator';
import { BoardGridRegistry, type GridDetachSource } from './BoardGridRegistry';
import { BoardPlaceFlightExecutor, type BoardPlaceFlightHooks } from './BoardPlaceFlightExecutor';
import { DragPlaceLinePresenter } from './DragPlaceLinePresenter';
import { PathFlowLinePresenter } from './PathFlowLinePresenter';
import { AttackRangePreviewPresenter } from './AttackRangePreviewPresenter';
import { BattleMapSpec } from '../map/BattleMapSpec';
import {
buildEnemyMarchPathGridIndices,
isEnemyMonsterWalkGrid,
} from '../map/MapPathQueries';
import { MapDragGuideHelper, type DragPlacementHint } from './MapDragGuideHelper';
import { AreaUnlockGuidePresenter } from './AreaUnlockGuidePresenter';
import { LevelUpTextFloater } from './GridUpgradeSpineEffect';
import { BattleAudio } from '../audio/BattleAudio';
import { BagTextDragDebugLog } from '../debug/BagTextDragDebugLog';
import { AiBattleBridge } from '../ai/bridge/AiBattleBridge';
import { AI_VIEW_LOCAL_CAMP } from '../ai/runtime/AiCampArea';
import { HeadIconManager } from '../../manager/HeadIconManager';
import { BattleGameRewriteGlyphPresenter } from '../../ui/BattleGame/BattleGameRewriteGlyphPresenter';
import 'db://assets/mx/extension/NodeDragExt';
/** 主动 skill 拖放落点:地图 occupied 格 / 背包普通单字槽 */
export type BattleBuffSkillDropTarget =
| { kind: 'map'; gridIndex: number }
| { kind: 'bag'; slotIndex: number };
/** 千里眼攻击范围表现参数(须在 apply 前解析) */
export interface AttackRangeBuffFxPreview {
/** 圆心世界坐标 */
centerWorld: Vec3;
/** 加成前射程(像素) */
baseRangePx: number;
/** 加成后射程(像素) */
endRangePx: number;
}
/** 妙手神偷落点:优先地图空 Put,否则背包空槽 */
export type StealGlyphReceivePlacement =
| { kind: 'map'; gridIndex: number }
| { kind: 'bag'; slotIndex: number };
/** 妙手神偷目标拒绝原因 */
export type StealGlyphRejectReason = 'composed' | 'invalid' | 'no_space';
/** 幻魂镜目标拒绝原因;可施放则 null */
export type ConfuseHeroRejectReason = 'invalid' | 'single_glyph' | 'not_hero' | 'already_charmed' | 'not_combat';
/** @deprecated 使用 BattleBuffSkillDropTarget */
export type BattleBuffRandomLevelDropTarget = BattleBuffSkillDropTarget;
/** 背包 + 地图字块拖拽视图 */
export class CocosBagBoardView {
/** 本机已绑定的棋盘视图 */
private static _bound: CocosBagBoardView | null = null;
/** 本机棋盘视图;未绑定时为 null */
static get bound(): CocosBagBoardView | null {
return this._bound;
}
private _battle: ProjectBattleHandle | null = null;
private _bagSession: BattleBagSession | null = null;
private _deploySync: DeploySync | null = null;
private _map: MapManager | null = null;
private _bagList: Node | null = null;
/** ui_textList 开启 / 待解锁 / 未解锁 */
private readonly _land = new TextListLandHost();
/** 开格展示字块补显重试帧计数(textGrid 预制体异步加载未就绪时留空格) */
private _landGlyphRetryFrame = 0;
/** AI 神偷落背包时飞行目标(BattleGame ui_otherBag) */
private _otherBagAnchor: Node | null = null;
private _mapRoot: Node | null = null;
/** 地图字专用层(BattleGame ui_textGrids);未绑定时回退格子节点 */
private _textGridLayer: Node | null = null;
private _dragLayer: Node | null = null;
private _lineNode: Node | null = null;
/** 出怪口→受击点流动箭头(由 UI 注入) */
private _pathFlow: PathFlowLinePresenter | null = null;
/** 背包/地图/飞行字块唯一归属索引 */
private _grids = new BoardGridRegistry();
private get _slotGrids(): Map<number, TextGrid> {
return this._grids.slotGrids;
}
private get _mapGrids(): Map<number, TextGrid> {
return this._grids.mapGrids;
}
/** 备战 type3 扩展格(无字块节点)的拖拽热区 */
private _prepType3Surfaces = new Map<number, Node>();
/** type3 限时拖动到期后重新绑定热区 */
private _prepType3LockTimer: ReturnType<typeof setTimeout> | null = null;
/** 5s 窗口已到期但 type3 热区 drag 未结束,松手后再卸热区 */
private _prepType3LockPending = false;
/** 当前由 prepType3 热区发起的拖动(计时到期不得销毁该热区) */
private _prepType3DraggingIndex: number | null = null;
private _linePresenter = new DragPlaceLinePresenter();
private _rangePresenter = new AttackRangePreviewPresenter();
/** 背包可解锁地图区域时的连线 / 虚线框引导 */
private _unlockGuide = new AreaUnlockGuidePresenter();
private _activeDrag: {
grid: TextGrid;
source: DragSource;
type3Backup: { gridIndex: number; tile: MapTileRecord } | null;
lineDragMode?: boolean;
/** 地图拖起快照:drop 时校验,避免 rebuild 后 location/source 与盘面不一致 */
mapDrag?: { fromGridIndex: number; weapon2Id: number; tileSource?: TileSource };
/** BagTextDragDebugLog 序号 */
debugSeq?: number;
} | null = null;
/** 最近一次字块拖动诊断序号(飞行落地后补打) */
private _textDragDebugSeq = 0;
/** 地图落子飞行动画进行中(须与 _activeDrag 一并阻塞 rebuild) */
private _mapDropAnimInflight = 0;
/**
* 落子飞行世代。刷新/rebuild 时递增,作废进行中的 onAnimDone:
* 禁止对「已刷新」的背包再 drop,否则会清槽并掐掉入场 tween(空槽观感)。
*/
private _placeFlightGen = 0;
/** rebuild 因拖放/飞行动画暂缓,结束后补一次 */
private _pendingSessionRebuild = false;
/** 背包刷新暂缓,结束后仅重建背包槽(不碰地图) */
private _pendingBagRebuild: { playEntrance: boolean; fromWorld?: Readonly<Vec3> | null } | null = null;
/** 防止 reconcile / 事件在 rebuild 中途重入导致背包槽被地图抢走 */
private _rebuildInProgress = false;
/**
* 落子视图同步阶段。
* awaitingCommit:drop 已 emit 事件,禁止全量 rebuild(否则清空索引后又 spawn → 叠字)
* patchReady:commitViews 已完成,flush 时只做增量 patch
*/
private _dropSyncPhase: 'idle' | 'awaitingCommit' | 'patchReady' = 'idle';
/** drop 已 emit MapUpgraded,须等 commitViews 后再做升级表现(避免 purge 误回收飞行字块) */
private _deferredMapUpgradeGrid: number | null = null;
/** drop 已 emit BagMerged,须等 commitViews 后再做升级表现 */
private _deferredBagUpgradeSlot: number | null = null;
/** 属性卡改字:复用文昌笔纯表现 */
private readonly _rewriteGlyphFx = new BattleGameRewriteGlyphPresenter();
/** 上次写入 TextGrid 的绑定键(不依赖 TextGrid 新方法,避免预览旧原型无 isBoundTo) */
private readonly _gridBindKey = new Map<TextGrid, string>();
private _mergeListener: ((e: MergeEvent) => void) | null = null;
/** 背包拖线悬停中的目标(地图格 / 背包槽,互斥显示 select) */
private _dragSelectTarget: { kind: 'map' | 'bag'; index: number; hint?: DragPlacementHint } | null = null;
/** 点击地图字块查看 type1 攻击范围(与拖拽预显互斥) */
private _mapRangeSelectTarget: number | null = null;
/** 当前打开武器详情的选中目标(再次点击同格关闭) */
private _textSelectTarget: { kind: 'map' | 'bag'; index: number } | null = null;
/** 点击背包字块查看 type1 攻击范围时高亮的槽位 */
private _bagRangeSelectSlot: number | null = null;
/** 已绑定地图格点击的节点(unbind 时不依赖 map.mapGrids) */
private _mapGridClickNodes: Node[] = [];
/** 同帧重复派发去重(mapGrid + TextGrid 双路径) */
private _textInteractDedupFrame = -1;
/** 同帧重复派发去重 key */
private _textInteractDedupKey = '';
/**
* 下一次 BagRefreshed 使用 UI 指定的入场起点(一次重建)。
* 避免 tryNormalRefresh 内 emit 先无动画重建、UI 再 rebuild 造成抖音上动画被冲掉。
*/
private _bagEntranceArmed = false;
private _bagEntranceFrom: Readonly<Vec3> | null = null;
/** 背包入场未全部落地:刷新按钮 / 自动合成仍忙碌;单格落地后即可拖 */
private _bagEntrancePlaying = false;
/** 背包入场期间:外部勿 rebuild */
isBagEntranceBusy(): boolean {
return this._bagEntrancePlaying;
}
/** 强制结束背包入场锁(防 tween 中断后刷新按钮永久无响应) */
clearBagEntranceBusy(): void {
if (!this._bagEntrancePlaying && !this._pendingSessionRebuild && !this._pendingBagRebuild) return;
if (this._bagEntrancePlaying) {
BagSlotEntranceAnimator.stopAll(this._slotGrids, this._bagList, true);
this._bagEntrancePlaying = false;
const layout = this._bagList?.getComponent(Layout);
if (layout?.isValid) layout.enabled = true;
}
this._flushAllPendingRebuilds();
this._refreshDragEnabled();
}
/** session 有字但视图缺失/父节点错位时按数据补齐(地图重建前后均须调用) */
private _fillMissingBagSlotGrids(usedTextGrids?: MembershipSet<TextGrid>): void {
if (!this._bagSession || !this._bagList) return;
const slots = this._bagSession.getBagSlots();
for (let i = 0; i < slots.length; i++) {
const record = slots[i];
const slotNode = this._bagSlotNodeAt(i);
if (!record) {
if (slotNode?.isValid && !this._findFlightGridForBag(i)) this._purgeTextGridsOnNode(slotNode);
this._slotGrids.delete(i);
continue;
}
if (!slotNode?.isValid) continue;
if (this._findFlightGridForBag(i)) continue;
const repaired = this.ensureBagSlotGrid(i);
if (repaired && usedTextGrids) membershipAdd(usedTextGrids, repaired);
else if (!repaired) {
console.log(
'[CocosBagBoardView] 背包槽补齐失败',
'slot=', i,
'weapon2Id=', record.weapon2Id,
);
}
}
}
/** 节点是否挂在 ui_textList 背包槽下 */
private _isNodeUnderBagList(node: Node | null | undefined): boolean {
if (!node?.isValid || !this._bagList?.isValid) return false;
let p: Node | null = node;
while (p) {
if (p === this._bagList) return true;
p = p.parent;
}
return false;
}
/** 槽位下标对应的 ui_textList 格子节点(gridN → N;temp 与无名节点不算) */
private _bagSlotNodeAt(slotIndex: number): Node | null {
if (!this._bagList?.isValid) return null;
const children = this._bagList.children;
for (let i = 0; i < children.length; i++) {
if (TextListLandHost.indexOfNode(children[i]) === slotIndex) return children[i];
}
return null;
}
/** 地图字父节点:专用层优先 */
private _mapTextLayer(): Node | null {
return this._textGridLayer?.isValid ? this._textGridLayer : null;
}
/**
* 地图字落地挂点与世界坐标。
* @param gridIndex 地图格
*/
private _mapPlaceAttach(gridIndex: number): { attachParent: Node | null; restWorld: Vec3 | null } {
const cell = this._map?.getMapGridByIndex(gridIndex)?.node ?? null;
if (!cell?.isValid) return { attachParent: null, restWorld: null };
const layer = this._mapTextLayer();
return {
attachParent: layer ?? cell,
restWorld: cell.worldPosition.clone(),
};
}
/**
* 把字块挂到专用层并对齐格子世界坐标。
* @param grid 字块
* @param gridIndex 地图格
*/
private _placeMapTextGrid(grid: TextGrid, gridIndex: number): boolean {
if (!grid?.node?.isValid) return false;
const land = this._mapPlaceAttach(gridIndex);
if (!land.attachParent?.isValid || !land.restWorld) return false;
if (grid.node.parent !== land.attachParent) {
grid.node.parent = land.attachParent;
}
grid.node.setWorldPosition(land.restWorld.x, land.restWorld.y, land.restWorld.z);
return true;
}
/**
* 当前地图格上的字块(索引 / 专用层 / 格子残留)。
* @param gridIndex 地图格
*/
private _mapGridOnCell(gridIndex: number): TextGrid | null {
const tracked = this._mapGrids.get(gridIndex);
if (tracked?.isValid && tracked.node?.isValid) return tracked;
const layer = this._mapTextLayer();
if (layer?.isValid) {
const children = layer.children;
for (let i = 0; i < children.length; i++) {
const tg = children[i].getComponent(TextGrid);
if (!tg?.isValid) continue;
const loc = tg.location;
if (loc?.kind === 'map' && loc.gridIndex === gridIndex) return tg;
}
}
const cell = this._map?.getMapGridByIndex(gridIndex)?.node;
return cell?.isValid ? this._grids.findMapGridOnCell(cell) : null;
}
/** 节点是否挂在地图字层或格子下 */
private _isNodeUnderMapCell(node: Node | null | undefined): boolean {
if (!node?.isValid) return false;
const layer = this._mapTextLayer();
if (layer?.isValid && node.parent === layer) return true;
if (!this._map?.mapGrids?.length) return false;
const cells = this._map.mapGrids;
let p: Node | null = node.parent;
while (p) {
if (layer?.isValid && p === layer) return true;
for (let i = 0; i < cells.length; i++) {
if (cells[i]?.node === p) return true;
}
p = p.parent;
}
return false;
}
/** 场上字块:逻辑/索引/父节点任一表明在地图,背包重建不得回收或挪走 */
private _isMapHostedTextGrid(grid: TextGrid | undefined): boolean {
if (!grid?.node?.isValid) return false;
if (this._isDragGhostNode(grid.node)) return false;
if (grid.location?.kind === 'map') return true;
for (const g of this._mapGrids.values()) {
if (g === grid) return true;
}
return this._isNodeUnderMapCell(grid.node);
}
/** 拉线拖动手影,不是场上真身 */
private _isDragGhostNode(node: Node | null | undefined): boolean {
return !!node?.isValid && node.name === CocosBagBoardView.DRAG_GHOST_NAME;
}
/** 背包重建前:把所有地图格上的 TextGrid 纳入保护集(不仅 _mapGrids 索引) */
private _collectMapProtectedGrids(
out: MembershipSet<TextGrid>,
prevMap?: ReadonlyMap<number, TextGrid>,
): void {
this._grids.collectMapProtected(
out,
this._map,
prevMap,
(idx) => !!this._bagSession?.merge.map.hasTile(idx),
this._textGridLayer,
);
}
/** 背包重建后:按 session + 场景父节点对齐 _mapGrids */
private _reconcileMapGridTracking(): void {
if (!this._bagSession || !this._map) return;
const session = this._bagSession.merge.map;
this._grids.reconcileMapFromScene(
this._map,
(idx) => session.hasTile(idx),
(node) => this._isNodeUnderMapCell(node),
this._textGridLayer,
);
}
/**
* 落子成功后立刻提交视图:写 location、索引、setData。
* 不依赖 deferred 全量 rebuild(否则 refresh 可能在 rebuild 前误回收场上字块)。
*/
private _commitMapTextGridView(gridIndex: number, grid: TextGrid): void {
if (!this._bagSession || !this._map || !grid?.isValid || !grid.node?.isValid) return;
const tile = this._bagSession.merge.map.getTile(gridIndex);
if (!tile) return;
const occupant = this._mapGridOnCell(gridIndex);
if (occupant && occupant !== grid) {
console.warn('[CocosBagBoardView] commitMap 格上真身与提交块不一致', gridIndex, occupant.weapon2Id, grid.weapon2Id);
if (occupant.weapon2Id === tile.weapon2Id || grid.weapon2Id !== tile.weapon2Id) {
grid = occupant;
}
}
if (!this._placeMapTextGrid(grid, gridIndex)) return;
const entity = this._buildMapTileEntity(gridIndex);
if (!entity) return;
if (!this._isGridBoundTo(grid, entity)) {
this._writeGridData(grid, entity);
}
grid.setRuinStyle(!!tile.ruined);
grid.rememberOrigin();
grid.node.name = 'textGrid';
this._grids.setMapGrid(gridIndex, grid);
grid.enableDrag(this._canDrag());
this._bindDrag(grid);
if (this._textDragDebugSeq > 0) {
BagTextDragDebugLog.snap(this._textDragDebugSeq, 'commitMap', grid, `map=${gridIndex}`);
}
}
/** 落子成功后提交背包槽视图 */
private _commitBagTextGridView(slotIndex: number, grid: TextGrid): void {
if (!this._bagSession || !this._bagList || !grid?.isValid || !grid.node?.isValid) return;
const record = this._bagSession.getBagSlots()[slotIndex];
if (!record) return;
const slotNode = this._bagSlotNodeAt(slotIndex);
if (!slotNode?.isValid) return;
const entity = this._buildBagSlotEntity(slotIndex);
if (!entity) return;
const live = this._resolveBagSlotLiveGrid(slotIndex, slotNode, record.weapon2Id);
if (live && live !== grid) {
if (grid.weapon2Id === 0) {
const stale = grid;
grid = live;
this._forceRecycleTextGrid(stale);
} else {
console.warn('[CocosBagBoardView] commitBag 槽上残留旧数字', slotIndex, live.weapon2Id, grid.weapon2Id);
}
}
if (grid.node.parent !== slotNode) {
const occupant = this._grids.findMapGridOnCell(slotNode);
if (occupant && occupant !== grid && occupant.weapon2Id !== 0) {
grid = occupant;
} else {
if (occupant?.isValid && occupant !== grid && occupant.weapon2Id === 0) {
this._forceRecycleTextGrid(occupant);
}
grid.node.parent = slotNode;
grid.node.setPosition(0, 0, 0);
}
}
this._bindBagSlotGrid(slotIndex, slotNode, grid, entity, !!record.ruined);
grid.sweepForeignNumbers(slotNode);
if (this._textDragDebugSeq > 0) {
BagTextDragDebugLog.snap(this._textDragDebugSeq, 'commitBag', grid, `slot=${slotIndex}`);
}
}
/** drop 成功后按目标格提交主字块与互换字块 */
private _commitViewsAfterSuccessfulDrop(
grid: TextGrid,
target: DropTarget,
swap?: { grid: TextGrid; target: DropTarget } | null,
): void {
if (target.kind === 'map') {
this._commitMapTextGridView(target.gridIndex, swap ? grid : this._keeperAfterMapDrop(grid, target.gridIndex));
} else {
const slotNode = this._bagSlotNodeAt(target.slotIndex);
const incoming = swap && grid.weapon2Id !== 0 ? grid : this._keeperAfterDrop(grid, slotNode);
this._commitBagTextGridView(target.slotIndex, incoming);
}
if (swap?.grid?.isValid) {
if (swap.target.kind === 'map') {
this._commitMapTextGridView(swap.target.gridIndex, swap.grid);
} else {
this._commitBagTextGridView(swap.target.slotIndex, swap.grid);
}
}
this._dropSyncPhase = 'patchReady';
}
/**
* 地图落子:飞来的块 vs 专用层上已有真身。
* @param incoming 飞来的字块
* @param gridIndex 落点格
*/
private _keeperAfterMapDrop(incoming: TextGrid, gridIndex: number): TextGrid {
if (!incoming?.isValid) return incoming;
const other = this._mapGridOnCell(gridIndex);
if (!other?.isValid || other === incoming) return incoming;
if (this._isKeepDigTextGrid(incoming) && this._isKeepDigTextGrid(other)) {
this._forceRecycleTextGrid(other);
return incoming;
}
this._forceRecycleTextGrid(incoming);
return other;
}
/**
* 空格/交换:飞来的块就是真身。
* 合成:格上已有块才是真身,飞来的是材料,回池。
* 蟠桃树互换误入本路径时:飞来的块是真身,回池落点残留。
*/
private _keeperAfterDrop(incoming: TextGrid, parent: Node | null | undefined): TextGrid {
if (!incoming?.isValid) return incoming;
const other = this._grids.findOtherGridOnCell(parent, incoming);
if (!other?.isValid) return incoming;
if (this._isKeepDigTextGrid(incoming) && this._isKeepDigTextGrid(other)) {
this._forceRecycleTextGrid(other);
return incoming;
}
this._forceRecycleTextGrid(incoming);
return other;
}
private _isKeepDigTextGrid(grid: TextGrid): boolean {
if (!grid?.isValid) return false;
return WeaponComposeConfigProvider.instance.passiveSpawnKeepsDigCell(grid.weapon2Id);
}
/** session 有 tile 时保证该格有视图;格上已有字或真身在飞时禁止再 spawn */
private _ensureMapTileView(gridIndex: number): void {
if (!this._bagSession || !this._map) return;
if (!this._bagSession.merge.map.getTile(gridIndex)) return;
const onCell = this._mapGridOnCell(gridIndex);
if (!onCell && (this._findFlightGridForMap(gridIndex) || this._mapDropAnimInflight > 0)) {
return;
}
const entity = this._buildMapTileEntity(gridIndex);
if (!entity) return;
if (onCell?.isValid) {
if (this._isGridBoundTo(onCell, entity)) {
this._placeMapTextGrid(onCell, gridIndex);
this._grids.setMapGrid(gridIndex, onCell);
return;
}
this._commitMapTextGridView(gridIndex, onCell);
return;
}
const spawned = this._spawnTextGrid(entity, this._mapTextLayer() ?? this._map.getMapGridByIndex(gridIndex)?.node ?? this._mapRoot!, this._canDrag());
if (spawned) this._commitMapTextGridView(gridIndex, spawned);
}
/** 落子/互换收尾:无视飞行残留,强制把该格视图对齐 session(蟠桃树交换丢皮时用) */
private _repairMapTileViewAfterDrop(gridIndex: number): void {
if (!this._bagSession || !this._map) return;
if (!this._bagSession.merge.map.hasTile(gridIndex)) return;
const entity = this._buildMapTileEntity(gridIndex);
if (!entity) return;
let onCell = this._mapGridOnCell(gridIndex);
if (!onCell?.isValid) {
const parent = this._mapTextLayer() ?? this._map.getMapGridByIndex(gridIndex)?.node;
if (!parent?.isValid) return;
onCell = this._spawnTextGrid(entity, parent, this._canDrag());
}
if (!onCell?.isValid) return;
this._commitMapTextGridView(gridIndex, onCell);
onCell.ensureBagGlyphVisible();
this._placeMapTextGrid(onCell, gridIndex);
onCell.rememberOrigin();
onCell.enableDrag(this._canDrag());
this._bindDrag(onCell);
}
/** 该地图格正在飞的真身(detachForFlight 已摘索引) */
private _findFlightGridForMap(gridIndex: number, flights?: readonly TextGrid[]): TextGrid | null {
const list = flights ?? this._grids.flightGrids;
for (let i = 0; i < list.length; i++) {
const g = list[i];
if (!g?.isValid) continue;
const loc = g.location;
if (loc?.kind === 'map' && loc.gridIndex === gridIndex) return g;
}
return null;
}
/** 该背包槽正在飞的真身 */
private _findFlightGridForBag(slotIndex: number): TextGrid | null {
const flights = this._grids.flightGrids;
for (let i = 0; i < flights.length; i++) {
const g = flights[i];
if (!g?.isValid) continue;
const loc = g.location;
if (loc?.kind === 'bag' && loc.slotIndex === slotIndex) return g;
}
return null;
}
/** 构建地图格 TextGrid 快照 */
private _buildMapTileEntity(gridIndex: number): TextGridEntityData | null {
if (!this._bagSession) return null;
const tile = this._bagSession.merge.map.getTile(gridIndex);
if (!tile) return null;
const composition = this._bagSession.compositions.find(
c => c.gridIndices.includes(gridIndex),
) ?? null;
return buildTextGridEntityData(
{ kind: 'map', gridIndex },
tile,
{ composition, player: this._battle?.player ?? gg.game.localPlayer },
);
}
/** 构建背包槽 TextGrid 快照 */
private _buildBagSlotEntity(slotIndex: number): TextGridEntityData | null {
if (!this._bagSession) return null;
const record = this._bagSession.getBagSlots()[slotIndex];
if (!record) return null;
return buildTextGridEntityData(
{ kind: 'bag', slotIndex },
record,
{ player: this._battle?.player ?? gg.game.localPlayer },
);
}
/**
* 槽上唯一真身:在槽上且与 session 同字的那一块。
* 扫全部子节点,不拿 weapon2Id=0 的回池空壳当真身。
*/
private _resolveBagSlotLiveGrid(slotIndex: number, slotNode: Node, weapon2Id: number): TextGrid | null {
const tracked = this._slotGrids.get(slotIndex);
if (tracked?.isValid && tracked.node?.isValid && tracked.node.parent === slotNode && tracked.weapon2Id === weapon2Id) {
return tracked;
}
if (slotNode?.isValid) {
for (let i = 0; i < slotNode.children.length; i++) {
const child = slotNode.children[i];
if (child.name === CocosBagBoardView.DRAG_GHOST_NAME) continue;
const tg = child.getComponent(TextGrid);
if (tg?.isValid && tg.weapon2Id === weapon2Id) return tg;
}
}
const occupant = this._grids.findMapGridOnCell(slotNode);
if (occupant?.isValid && occupant.weapon2Id !== 0) return occupant;
if (tracked?.isValid && tracked.node?.isValid && tracked.node.parent === slotNode && tracked.weapon2Id !== 0) {
return tracked;
}
return null;
}
/** 索引 = 场景真身,清掉同槽第二块,写入 session */
private _bindBagSlotGrid(slotIndex: number, slotNode: Node, grid: TextGrid, entity: TextGridEntityData, ruined: boolean): TextGrid {
gg.res.detachFromPool(grid.node);
this._grids.setBagGrid(slotIndex, grid);
this._purgeUntrackedBagSlotGrids(slotNode, slotIndex);
if (!this._isGridBoundTo(grid, entity)) this._writeGridData(grid, entity);
grid.setRuinStyle(!!ruined);
grid.rememberOrigin();
grid.node.name = 'textGrid';
grid.enableDrag(this._canEnableBagGridDrag(grid));
this._bindDrag(grid);
grid.ensureBagGlyphVisible();
return grid;
}
/** 快照绑定键:位置 + 字 + 级 + 组合 */
private _snapshotBindKey(data: TextGridEntityData): string {
const loc = data.location;
const locKey = loc.kind === 'bag' ? `b${loc.slotIndex}` : `m${loc.gridIndex}`;
const c = data.composition;
return `${locKey}|${Number(data.tile.id)}|${data.level}|${Number(data.weapon1Id) || 0}|${c ? composeKey(c) : ''}`;
}
/** 该 TextGrid 是否已写入过相同快照 */
private _isGridBoundTo(grid: TextGrid, data: TextGridEntityData): boolean {
if (!grid?.isValid || !grid.node?.isValid) return false;
return this._gridBindKey.get(grid) === this._snapshotBindKey(data);
}
/** setData 并记下绑定键 */
private _writeGridData(grid: TextGrid, data: TextGridEntityData): void {
grid.setData(data);
this._gridBindKey.set(grid, this._snapshotBindKey(data));
}
/** 回池时丢掉绑定键 */
private _forgetGridBind(grid: TextGrid | null | undefined): void {
if (grid) this._gridBindKey.delete(grid);
}
/** 地图复用候选不得是仍挂在背包槽上的字块(仅用于地图 rebuild,勿用于背包槽循环) */
private _rejectBagHostedGrid(grid: TextGrid | undefined): TextGrid | undefined {
if (!grid?.node?.isValid) return undefined;
if (this._isNodeUnderBagList(grid.node)) return undefined;
return grid;
}
/** prevMap 中仍对应 session 地图格的节点(全量 rebuild 背包阶段不得挪到背包) */
private _isActivePrevMapGrid(grid: TextGrid, prevMap: ReadonlyMap<number, TextGrid>): boolean {
for (const [idx, g] of prevMap) {
if (g === grid && this._bagSession!.merge.map.hasTile(idx)) return true;
}
return false;
}
/** 背包复用候选不得是场上地图字块 */
private _canReuseTextGridForBag(
grid: TextGrid | undefined,
prevMap?: ReadonlyMap<number, TextGrid>,
): grid is TextGrid {
if (!grid?.node?.isValid) return false;
if (this._isMapHostedTextGrid(grid)) return false;
if (prevMap && this._isActivePrevMapGrid(grid, prevMap)) return false;
return true;
}
/** 清掉槽内未纳入 _slotGrids 的残留 TextGrid,避免被地图 rebuild 误认领 */
private _purgeUntrackedBagSlotGrids(slotNode: Node, slotIndex: number): void {
if (!slotNode?.isValid) return;
const tracked = this._slotGrids.get(slotIndex);
for (const child of [...slotNode.children]) {
const tg = child.getComponent(TextGrid);
if (!tg?.isValid || tg === tracked) continue;
// 交换飞行列表里的旧字块也要回池,否则槽上留下上一次的数字
this._forceRecycleTextGrid(tg);
}
}
/** 保证背包槽有 TextGrid(逻辑有 record、视图缺失或节点已回池时重建) */
ensureBagSlotGrid(slotIndex: number): TextGrid | null {
if (!this._bagSession || !this._bagList) return null;
const slotNode = this._bagSlotNodeAt(slotIndex);
const record = this._bagSession.getBagSlots()[slotIndex];
if (!record || !slotNode?.isValid) return null;
const entity = buildTextGridEntityData(
{ kind: 'bag', slotIndex },
record,
{ player: this._battle?.player ?? gg.game.localPlayer },
);
if (!entity) return null;
const live = this._resolveBagSlotLiveGrid(slotIndex, slotNode, record.weapon2Id);
if (live?.isValid && live.node.parent === slotNode) {
if (this._slotGrids.get(slotIndex) === live && this._isGridBoundTo(live, entity)) {
this._purgeUntrackedBagSlotGrids(slotNode, slotIndex);
return live;
}
return this._bindBagSlotGrid(slotIndex, slotNode, live, entity, !!record.ruined);
}
const occupant = this._grids.findMapGridOnCell(slotNode);
if (occupant?.isValid && occupant.weapon2Id !== 0) {
return this._bindBagSlotGrid(slotIndex, slotNode, occupant, entity, !!record.ruined);
}
if (occupant?.isValid && occupant.weapon2Id === 0) {
this._forceRecycleTextGrid(occupant);
}
const existing = this._slotGrids.get(slotIndex);
if (
existing?.node?.isValid
&& !existing.node.parent
&& !this._isMapHostedTextGrid(existing)
&& this._grids.flightGrids.indexOf(existing) < 0
) {
gg.res.detachFromPool(existing.node);
existing.node.parent = slotNode;
existing.node.active = true;
existing.node.setPosition(0, 0, 0);
return this._bindBagSlotGrid(slotIndex, slotNode, existing, entity, !!record.ruined);
}
if (existing) this._grids.deleteBagSlot(slotIndex);
const grid = this._spawnTextGrid(entity, slotNode, false);
if (!grid) return null;
return this._bindBagSlotGrid(slotIndex, slotNode, grid, entity, !!record.ruined);
}
private _shouldDeferSessionRebuild(): boolean {
return this._bagEntrancePlaying
|| this._autoMergeAnimating
|| this._activeDrag != null
|| this._mapDropAnimInflight > 0;
}
/** 盘面变化:只增量对齐变化的槽/格,禁止全量 rebuild 扫场 */
private _requestSessionRebuild(
playBagEntrance = false,
fromWorld?: Readonly<Vec3> | null,
): void {
if (this._dropSyncPhase !== 'idle') {
this._pendingSessionRebuild = true;
return;
}
if (this._shouldDeferSessionRebuild()) {
this._pendingSessionRebuild = true;
return;
}
if (playBagEntrance) {
this._rebuildFromSession(true, fromWorld);
return;
}
this._applyIncrementalDropPatch();
}
/** 背包刷新专用:只重建 ui_textList,不跑地图 forEach、不 reconcile */
private _requestBagRebuild(
playBagEntrance = false,
fromWorld?: Readonly<Vec3> | null,
): void {
if (this._shouldDeferSessionRebuild()) {
this._pendingBagRebuild = { playEntrance: playBagEntrance, fromWorld };
return;
}
this._rebuildBagFromSession(playBagEntrance, fromWorld);
}
private _flushPendingSessionRebuild(): void {
if (this._shouldDeferSessionRebuild()) return;
if (this._dropSyncPhase === 'patchReady') {
this._pendingSessionRebuild = false;
this._dropSyncPhase = 'idle';
this._applyIncrementalDropPatch();
return;
}
if (!this._pendingSessionRebuild) return;
this._pendingSessionRebuild = false;
if (this._dropSyncPhase === 'awaitingCommit') {
this._dropSyncPhase = 'idle';
}
this._applyIncrementalDropPatch();
}
/** drop 前标记:随后 emit 的 MergeEvent 不得触发全量 rebuild */
private _beginDropViewSync(): void {
this._dropSyncPhase = 'awaitingCommit';
}
/** drop 失败且未 commit 时复位 */
private _cancelDropViewSync(): void {
if (this._dropSyncPhase === 'awaitingCommit') {
this._dropSyncPhase = 'idle';
}
}
/**
* 按 session 增量对齐视图:只补空、只写绑定期变化的槽/格,不扫场 setData。
*/
private _applyIncrementalDropPatch(): void {
if (!this._bagSession || !this._map || !this._bagList) return;
const slots = this._bagSession.getBagSlots();
for (let i = 0; i < slots.length; i++) {
if (!slots[i]) this._vacateBagSlotView(i);
}
const mapCells = this._map.mapGrids;
if (mapCells?.length) {
for (let i = 0; i < mapCells.length; i++) {
if (this._bagSession.merge.map.hasTile(i)) continue;
// 敌方 CampArea 由 AI 无头 bag 占用,勿按本机 session 清空 TextGrid/HeroActor
if (this._isEnemyPlaceCampCell(i)) continue;
this._vacateMapCellView(i);
}
}
this._fillMissingBagSlotGrids();
for (let i = 0; i < slots.length; i++) {
if (!slots[i]) continue;
const slotNode = this._bagSlotNodeAt(i);
if (!slotNode?.isValid) continue;
const live = this._resolveBagSlotLiveGrid(i, slotNode, slots[i].weapon2Id) ?? this.ensureBagSlotGrid(i);
if (!live?.isValid) continue;
const entity = this._buildBagSlotEntity(i);
if (entity && live.node.parent === slotNode && this._isGridBoundTo(live, entity)) continue;
this._commitBagTextGridView(i, live);
}
this._bagSession.merge.map.forEachTile((gridIndex) => {
this._ensureMapTileView(gridIndex);
});
this._reconcileMapGridTracking();
this._flushDeferredMergeUpgradePresentation();
if (this._deploySync && this._map.gridsInitialized) {
this._deploySync.reconcileFromMerge('dropPatch');
}
this._refreshSkillBindings();
this._refreshCanUpgradeHints();
this._refreshDragEnabled();
}
/** 落子 commit 完成后补播合成升级表现(等级 / 特效 / 部署同步) */
private _flushDeferredMergeUpgradePresentation(): void {
const mapGrid = this._deferredMapUpgradeGrid;
if (mapGrid != null) {
this._deferredMapUpgradeGrid = null;
if (this._bagSession?.merge.map.hasTile(mapGrid)) {
const tile = this._bagSession.merge.map.getTile(mapGrid);
const level = tile?.level ?? 1;
const grid = this._mapGrids.get(mapGrid);
if (grid?.isValid) {
grid.applyMapLevel(level);
grid.enableDrag(this._canDrag());
this._bindDrag(grid);
}
if (this._deploySync && this._map?.gridsInitialized) {
this._deploySync.reconcileFromMerge('mapUpgraded');
}
this._playMapUpgradeEffect(mapGrid);
this._refreshCanUpgradeHints();
this._refreshSkillBindings();
}
}
const bagSlot = this._deferredBagUpgradeSlot;
if (bagSlot != null) {
this._deferredBagUpgradeSlot = null;
const slotNode = this._bagSlotNodeAt(bagSlot);
if (this._bagSession?.getBagSlots()[bagSlot] && slotNode?.isValid) {
const grid = this._resolveBagTextGridForUpgrade(bagSlot, slotNode);
const record = this._bagSession.getBagSlots()[bagSlot];
if (grid?.isValid && record) {
this._grids.setBagGrid(bagSlot, grid);
grid.applyMapLevel(record.level ?? 1);
}
this._sanitizeBagSlotAfterMerge(bagSlot);
this._playBagUpgradeEffect(bagSlot);
this._refreshCanUpgradeHints();
}
}
}
private _vacateBagSlotView(slotIndex: number): void {
this._grids.deleteBagSlot(slotIndex);
const slotNode = this._bagSlotNodeAt(slotIndex);
if (!slotNode?.isValid) return;
for (const child of [...slotNode.children]) {
const tg = child.getComponent(TextGrid);
if (!tg?.isValid) continue;
if (this._grids.flightGrids.indexOf(tg) >= 0) continue;
this._forceRecycleTextGrid(tg);
}
}
/**
* session 无 tile 的地图格:清视图。
* @param gridIndex 地图格
* @param force 损毁等强制清;默认跳过飞行中的字块
*/
private _vacateMapCellView(gridIndex: number, force = false): void {
const tracked = this._mapGrids.get(gridIndex);
if (!force && tracked && this._grids.flightGrids.indexOf(tracked) >= 0) return;
this._grids.deleteMapCell(gridIndex);
HeroActorHost.instance.destroyAtGrid(gridIndex);
const recycle = (tg: TextGrid) => {
if (!tg?.isValid) return;
if (!force && this._grids.flightGrids.indexOf(tg) >= 0) return;
this._forceRecycleTextGrid(tg);
};
if (tracked) recycle(tracked);
const onCell = this._mapGridOnCell(gridIndex);
if (onCell && onCell !== tracked) recycle(onCell);
}
/** 格上是否有飞行中的字块 */
private _mapCellHasProtectedTextGrid(parent: Node | null | undefined): boolean {
if (!parent?.isValid) return false;
for (let i = 0; i < parent.children.length; i++) {
const tg = parent.children[i].getComponent(TextGrid);
if (tg?.isValid && this._grids.flightGrids.indexOf(tg) >= 0) return true;
}
return false;
}
/**
* 全量 rebuild 被推迟时仍立刻清掉 session 已无字的己方格。
* 妙手神偷等只发 ComposeChanged,否则拖拽/落子动画期间字块会残留数秒。
*/
private _vacateMissingMapTilesNow(): void {
if (!this._bagSession || !this._map) return;
const mapCells = this._map.mapGrids;
if (!mapCells?.length) return;
for (let i = 0; i < mapCells.length; i++) {
if (this._bagSession.merge.map.hasTile(i)) continue;
if (this._isEnemyPlaceCampCell(i)) continue;
this._vacateMapCellView(i);
}
}
/** 是否敌方放置区(CampAreaID=2);本机 bag 视图不得清空该区 AI 字 */
private _isEnemyPlaceCampCell(gridIndex: number): boolean {
const enemyCamp = aiPlaceCampForPlayer(this._resolveEnemyPlayerId());
const camp = (this._map?.getMapGridByIndex(gridIndex)?.CampAreaID ?? 0) | 0;
return camp === enemyCamp;
}
/** 对手 playerId(0↔1) */
private _resolveEnemyPlayerId(): number {
return (this._battle?.player?.playerId ?? 0) === 0 ? 1 : 0;
}
/** 屏幕 UI 坐标 → 敌方阵营 occupied 格 */
private _resolveEnemyCampOccupiedGridAtUiPos(uiPos: { x: number; y: number }): number | null {
if (!this._map || !this._battle) return null;
const grids = this._map.mapGrids;
if (!Array.isArray(grids)) return null;
const p = new Vec2(uiPos.x, uiPos.y);
let bestIndex: number | null = null;
let bestDist = Infinity;
for (const mg of grids) {
if (!mg?.node?.isValid) continue;
if (!this._isEnemyPlaceCampCell(mg.Index)) continue;
if (!this._enemyGridHasTile(mg.Index)) continue;
const ut = mg.node.getComponent(UITransform);
if (!ut) continue;
const rect = ut.getBoundingBoxToWorld();
if (!rect.contains(p)) continue;
const dist = Vec2.distance(p, rect.center);
if (dist < bestDist) {
bestDist = dist;
bestIndex = mg.Index;
}
}
return bestIndex;
}
/** 敌方地图格是否有字块(merge 逻辑为准,compositions 兜底) */
private _enemyGridHasTile(gridIndex: number): boolean {
const bridge = AiBattleBridge.inst;
const enemyBag = bridge.aiBagSession;
if (enemyBag?.merge.map.hasTile(gridIndex)) return true;
const enemyPlayer = this._battle?.getPlayer(this._resolveEnemyPlayerId());
if (!enemyPlayer) return false;
for (const comp of enemyPlayer.compositions) {
if (comp.gridIndices.includes(gridIndex)) return true;
}
return false;
}
/** 由敌方地图格构建幻魂镜目标(仅多格神将) */
private _buildConfuseHeroTarget(gridIndex: number): CharmTarget | null {
const battle = this._battle;
const map = this._map;
if (!battle || !map) return null;
if (!this._isEnemyPlaceCampCell(gridIndex)) return null;
const enemyBag = AiBattleBridge.inst.aiBagSession;
if (!enemyBag?.merge.map.hasTile(gridIndex)) return null;
if (!enemyBag.merge.isMultiCellCompositionAt(gridIndex)) return null;
const enemyPlayer = battle.getPlayer(this._resolveEnemyPlayerId());
if (!enemyPlayer) return null;
const comp = enemyPlayer.compositions.find(c => c.gridIndices.includes(gridIndex)) ?? null;
if (!comp) return null;
if (!isHeroActorWeapon1(comp.weapon1Id)) return null;
if (comp.gridIndices.length < 2) return null;
const entityId = findEntityIdByGridIndex(gridIndex, battle.session, map, enemyPlayer);
if (entityId == null) return null;
return {
entityId,
composeKey: composeKey(comp),
gridIndices: comp.gridIndices.slice(),
anchorGridIndex: comp.gridIndices[0],
};
}
/** 确认 session 已不持有该字块时强制回池(绕开场上保护) */
private _forceRecycleTextGrid(grid: TextGrid): void {
if (!grid?.node?.isValid) return;
this._forgetGridBind(grid);
this._grids.purgeGridFromIndices(grid);
BagSlotEntranceAnimator.stopGrid(grid, true);
grid.prepareForPool();
const node = grid.node;
if (node['resKey']) gg.res.putNode(node);
else node.destroy();
}
private _flushPendingBagRebuild(): void {
const pending = this._pendingBagRebuild;
if (!pending || this._shouldDeferSessionRebuild()) return;
this._pendingBagRebuild = null;
this._rebuildBagFromSession(pending.playEntrance, pending.fromWorld);
}
private _flushAllPendingRebuilds(): void {
this._flushPendingBagRebuild();
this._flushPendingSessionRebuild();
}
/** 作废进行中的落子飞行回调(刷新/重建前必须调用) */
private _invalidatePlaceFlights(): void {
this._placeFlightGen++;
this._mapDropAnimInflight = 0;
this._dropSyncPhase = 'idle';
this._deferredMapUpgradeGrid = null;
this._deferredBagUpgradeSlot = null;
}
/**
* 落子飞行动画收尾守卫。
* @returns false 表示本段飞行已被刷新/重建作废,调用方禁止再 drop
*/
private _finishPlaceFlightGuard(flightGen: number): boolean {
if (flightGen !== this._placeFlightGen) return false;
this._mapDropAnimInflight = Math.max(0, this._mapDropAnimInflight - 1);
return true;
}
/** 背包刷新入场 / 自动合成飞行动画期间不可触发下一次自动合成 */
get canAutoMerge(): boolean {
return !this._bagEntrancePlaying
&& !this._autoMergeAnimating
&& this._activeDrag == null
&& !!this._bagSession?.merge.prepActive;
}
/** 上一帧多字英雄组合 key,用于检测新合成 */
private _prevComposeKeys = createMembershipSet<string>();
private _composeKeysSeeded = false;
/** 待播合成音效的地图格(等 tile 事件 rebuild 后再播) */
private _pendingComposeFxGrids: number[] = [];
private _pendingComposeHeroIds: number[] = [];
private _composeFxFlushScheduled = false;
/** 拖动中正在抖动的可合成目标 */
private _mergeShakeGrids = createMembershipSet<TextGrid>();
/** 主动 buff 拖拽落点预览格 */
private _battleBuffPreviewIndex: number | null = null;
private _battleBuffPreviewBagSlot: number | null = null;
/** 幻魂镜多格预览(需整组清除) */
private readonly _confuseHeroPreviewGridIndices: number[] = [];
/** 主动 buff 施法期间抑制字块/地图格默认「升级效果」 */
private _suppressDefaultUpgradeFx = false;
/**
* 技能改等级时关掉合成升级特效。
* @param on 是否抑制
*/
setSuppressUpgradeFx(on: boolean): void {
this._suppressDefaultUpgradeFx = !!on;
}
/** 敌方出怪口→攻击点行军路线格(缚仙网 / 三昧真火落点) */
private _enemyMarchPathGrids: ReadonlyMembershipSet<number> | null = null;
/** 本局已放置的扩展格子计数(打点用) */
private _placedGridCount = 0;
/** 拉线拖动时跟随触摸点的半透明副本 */
private _dragGhost: TextGrid | null = null;
private static readonly DRAG_GHOST_OPACITY = 150;
/** 拉线拖动手影节点名 */
private static readonly DRAG_GHOST_NAME = '__bagDragGhost';
private _inkstoneWorldPosProvider: (() => Readonly<Vec3> | null) | null = null;
/** 背包出字入场开始(砚台 Spine 等) */
private _bagEntranceStartHandler: (() => void) | null = null;
/** 背包出字入场结束 */
private _bagEntranceEndHandler: (() => void) | null = null;
/** 技能产桃:本机格心飞向 ui_money;敌方格心 coinFly 原地弹跳 */
private _skillPeachFlyHandler: ((gridIndex: number, peachCount: number, ownerPlayerId: number) => void) | null = null;
/** 出字起点(btnRefresh)世界坐标 */
setInkstoneWorldPosProvider(provider: (() => Readonly<Vec3> | null) | null): void {
this._inkstoneWorldPosProvider = provider;
}
/**
* 背包出字入场起止回调。
* @param onStart 抛物线入场开始
* @param onEnd 入场结束(含 settle 打断)
*/
setBagEntranceHandler(onStart: (() => void) | null, onEnd: (() => void) | null): void {
this._bagEntranceStartHandler = onStart;
this._bagEntranceEndHandler = onEnd;
}
/**
* 技能产桃飞向 ui_money(由 UIBattleGame 绑定 coinFly)。
* @param handler 地图格与蟠桃数量
*/
setSkillPeachFlyHandler(handler: ((gridIndex: number, peachCount: number, ownerPlayerId: number) => void) | null): void {
this._skillPeachFlyHandler = handler;
}
/**
* 通知技能产桃演出。
* @param gridIndex 施法格
* @param peachCount 蟠桃数量
* @returns 是否已交给 UI 层
*/
notifySkillPeachGain(gridIndex: number, peachCount: number, ownerPlayerId = 0): boolean {
if (peachCount <= 0 || !this._skillPeachFlyHandler) return false;
this._skillPeachFlyHandler(gridIndex, peachCount, ownerPlayerId);
return true;
}
/** 帧更新:空开启槽补显(原蟠桃树 CD 表现已下线) */
tickPeachTreePresentations(): void {
this._tickLandGlyphRetry();
}
/** 空开启槽补显:每 10 帧检查一次,textGrid 预制体就绪后自动补齐;前 30 秒后停轮询 */
private _tickLandGlyphRetry(): void {
this._landGlyphRetryFrame++;
if (this._landGlyphRetryFrame >= 1800) return; // 30 秒后停
if (this._landGlyphRetryFrame % 10 !== 0) return;
if (!this._bagSession) return;
this._fillMissingBagSlotGrids();
}
getInkstoneWorldPosition(): Readonly<Vec3> | null {
return this._inkstoneWorldPosProvider?.() ?? null;
}
/** 声明下一次背包刷新入场由本视图在 BagRefreshed 时一次性播放 */
armBagEntrance(fromWorld?: Readonly<Vec3> | null): void {
this._bagEntranceArmed = true;
this._bagEntranceFrom = fromWorld ?? null;
}
/** 刷新失败时取消尚未消费的入场声明 */
disarmBagEntrance(): void {
this._bagEntranceArmed = false;
this._bagEntranceFrom = null;
}
/** 绑定 AI 神偷落背包时的飞行锚点(BattleGame ui_otherBag) */
setOtherBagAnchor(node: Node | null | undefined): void {
this._otherBagAnchor = node?.isValid ? node : null;
}
bind(
battle: ProjectBattleHandle,
map: MapManager,
bagList: Node,
mapRoot: Node,
dragLayer?: Node,
lineNode?: Node,
rangeNode?: Node,
textGridLayer?: Node,
pathFlow?: PathFlowLinePresenter | null,
): void {
this.unbind();
this._battle = battle;
this._map = map;
this._bagList = bagList;
this._land.bind(battle.LevelConfigId, bagList);
this._mapRoot = mapRoot;
this._textGridLayer = textGridLayer?.isValid ? textGridLayer : null;
this._dragLayer = dragLayer ?? mapRoot;
if (this._dragLayer?.isValid) {
this._dragLayer.active = true;
}
this._lineNode = lineNode ?? null;
this._pathFlow = pathFlow ?? null;
this._linePresenter.bind(this._lineNode);
this._rangePresenter.bind(rangeNode ?? null, mapRoot?.parent ?? null);
this._unlockGuide.bind(this._lineNode?.parent ?? mapRoot?.parent ?? null);
// 槽位下标与格子下标(gridN → N)一致,槽数按最大格子下标 + 1 分配(slot0 对应不到格子,保持空)
let maxLandIndex = 0;
const bagChildren = bagList.children;
for (let i = 0; i < bagChildren.length; i++) {
const idx = TextListLandHost.indexOfNode(bagChildren[i]);
if (idx != null && idx > maxLandIndex) maxLandIndex = idx;
}
this._bagSession = new BattleBagSession(map, battle.LevelConfigId, battle.session, maxLandIndex + 1);
battle.bagSession = this._bagSession;
this._bagSession.landStateOf = (slotIndex) => this._land.stateOf(slotIndex);
battle.charmController.bindBagView(this);
battle.charmController.bindMergeSession(this._bagSession.merge);
getTowerLevelDownHost().bindBagView(this);
getTowerRuinHost().bindBagView(this);
SkillAtkBuffBoardViewBind.bindView(this);
this._bagSession.resyncWalkabilityAndRefreshPaths();
this._deploySync = new DeploySync();
this._deploySync.bind(battle, map);
battle.deploySync = this._deploySync;
this._rebuildEnemyMarchPathCache();
this._mergeListener = (e) => this._onMergeEvent(e);
this._bagSession.on(MERGE_EVENT_ALL, this._mergeListener);
this._rewriteGlyphFx.bind(this, this._dragLayer);
this._bindMapGridClicks();
this._warmTextGridPool();
// 开局只给 tolockland 放字;normalland 留空;grid17 不出字
this._bagSession.autoFillOpenSlots();
this._rebuildBagFromSession(true);
this._refreshDragEnabled();
this._fillMissingBagSlotGrids();
CocosBagBoardView._bound = this;
}
/** 预热 textGrid 节点池,避免「背包字放到地图后再刷新」时 getNode 瞬时失败留空槽 */
private _warmTextGridPool(): void {
const warmed: Node[] = [];
for (let i = 0; i < 12; i++) {
const node = gg.res.getNode('prefab/', 'textGrid', GBundle.BattleGame);
if (!node?.isValid) break;
warmed.push(node);
}
for (let i = 0; i < warmed.length; i++) {
gg.res.putNode(warmed[i]);
}
if (warmed.length < 8) {
console.log('[CocosBagBoardView] textGrid 池预热不足', warmed.length);
}
}
/** 进入战斗:经典模式收起背包;连续模式保持背包与拖拽 */
enterCombatMode(): void {
this.cancelActiveDrag();
this._clearTextSelect();
if (this._battle?.session.isContinuousFlow) {
this._refreshDragEnabled();
return;
}
this._clearBagGrids();
this._clearPrepType3Surfaces();
this._refreshDragEnabled();
}
/**
* 弹窗/相位打断:终止当前拖动,字块回原位,清除幽灵与拉线。
* NodeDragExt 在遮罩抢走触摸时往往不发 DRAG_END,须由此显式收尾。
*/
cancelActiveDrag(): void {
this._prepType3DraggingIndex = null;
this._prepType3LockPending = false;
const drag = this._activeDrag;
this._activeDrag = null;
if (drag) {
const grid = drag.grid;
const surface = grid.dragNode;
if (surface?.isValid) {
surface.stopDrag();
surface.setPosition(0, 0, 0);
}
for (const s of this._prepType3Surfaces.values()) {
if (s?.isValid) {
s.stopDrag();
s.setPosition(0, 0, 0);
}
}
BagToMapPlaceAnimator.stop(grid);
grid.setDragFollow(true);
if (drag.type3Backup && this._bagSession) {
this._bagSession.revertType3Pickup(
drag.type3Backup.gridIndex,
drag.type3Backup.tile,
);
this._disposeEphemeralDragGrid(grid, drag.source);
this._clearDragGuide();
this._linePresenter.hide();
this._rebuildFromSession();
this._refreshDragEnabled();
if (drag.debugSeq) BagTextDragDebugLog.end(drag.debugSeq, grid, 'cancelActive:type3');
this._textDragDebugSeq = 0;
return;
}
grid.restoreOrigin();
this._disposeEphemeralDragGrid(grid, drag.source);
if (drag.debugSeq) BagTextDragDebugLog.end(drag.debugSeq, grid, 'cancelActive');
grid.ensureBagGlyphVisible();
this._textDragDebugSeq = 0;
}
this._clearDragGuide();
this._linePresenter.hide();
// 拖动打断后槽上可能丢 textGrid(池污染偷走),按 session 补回
this._fillMissingBagSlotGrids();
this._refreshDragEnabled();
}
/** 按当前相位同步字块/热区是否可拖(暂停弹窗关闭后须再调一次) */
refreshDragEnabled(): void {
this._refreshDragEnabled();
}
/** 波次间回到备战:恢复背包槽与拖拽 */
enterPrepMode(): void {
if (!this._bagSession || !this._battle) return;
this._rebuildFromSession();
}
/** 战斗快照恢复后全量重建棋盘表现 */
rebuildFromSession(playBagEntrance = false): void {
if (!this._bagSession || !this._battle) return;
this._rebuildFromSession(playBagEntrance);
}
/**
* 损毁后立刻清掉该格字块视图。
* @param gridIndex 地图格
*/
vacateDestroyedMapCell(gridIndex: number): void {
this._vacateMapCellView(gridIndex, true);
}
unbind(): void {
if (CocosBagBoardView._bound === this) {
CocosBagBoardView._bound = null;
}
this.disarmBagEntrance();
this.setInkstoneWorldPosProvider(null);
this.setOtherBagAnchor(null);
this.setBagEntranceHandler(null, null);
this.setSkillPeachFlyHandler(null);
this._unbindMapGridClicks();
this._clearRangeVisual();
this._textSelectTarget = null;
try {
this._clearDragGuide();
} catch (e) {
console.log('[CocosBagBoardView] _clearDragGuide failed', String(e));
}
this._unlockGuide.unbind();
this._linePresenter.unbind();
this._rangePresenter.unbind();
this._rewriteGlyphFx.unbind();
this._pathFlow?.endDrag();
this._pathFlow = null;
this._lineNode = null;
BagToMapPlaceAnimator.stop(this._activeDrag?.grid ?? null);
if (this._bagSession && this._mergeListener) {
this._bagSession.off(MERGE_EVENT_ALL, this._mergeListener);
}
this._mergeListener = null;
this._land.clear();
this._bagSession = null;
if (this._battle) {
this._battle.charmController.bindBagView(null);
this._battle.charmController.bindMergeSession(null);
getTowerLevelDownHost().bindBagView(null);
getTowerRuinHost().bindBagView(null);
SkillAtkBuffBoardViewBind.bindView(null);
this._battle.bagSession = null;
this._battle.deploySync = null;
}
this._deploySync?.unbind();
this._deploySync = null;
HeroActorHost.instance.destroyAll();
this._battle = null;
this._enemyMarchPathGrids = null;
this._prevComposeKeys.clear();
this._composeKeysSeeded = false;
this._pendingComposeFxGrids.length = 0;
this._pendingComposeHeroIds.length = 0;
this._composeFxFlushScheduled = false;
this._placedGridCount = 0;
try {
this._clearAllGrids();
} catch (e) {
console.log('[CocosBagBoardView] _clearAllGrids failed', String(e));
}
this._map = null;
this._textGridLayer = null;
}
refreshBag(): void {
if (!this._bagSession || !this._battle) return;
this._bagSession.refreshBag(this._battle.LevelConfigId, false);
}
/**
* 仅重建背包槽位视图(不重新 roll;用于 tryNormalRefresh 已扣费并 roll 之后)。
* @param fromWorld 砚台世界坐标;有则字从该点跳出落入格子
*/
rebuildBagSlots(playEntrance = true, fromWorld?: Readonly<Vec3> | null): void {
this._rebuildBagFromSession(playEntrance, fromWorld);
}
/**
* 取当前地图上所有字块视图(gridIndex → TextGrid)。
* 战斗表现层(CocosBattleTowerPresenter)通过它按格子定位塔的皮肤节点播放动画。
* 备战/战斗期间随字块增删实时维护,调用方无需缓存。
*/
getMapGrids(): Map<number, TextGrid> {
return this._mapGrids;
}
/**
* 取当前背包槽字块视图(slotIndex → TextGrid)。
*/
getBagGrids(): Map<number, TextGrid> {
return this._slotGrids;
}
/** 是否可与地图交互(备战拖放 / 升级类主动 buff) */
isMapInteractionEnabled(): boolean {
return this._canDrag() && this._activeDrag == null && !this._bagEntrancePlaying;
}
/** 是否可施放地图类主动 buff(备战 + 战斗,不含背包拖字中) */
isBattleBuffSkillEnabled(): boolean {
const battle = this._battle;
if (!battle || battle.session.ended) return false;
const phase = battle.session.phase;
if (phase === BattlePhase.WAIT_REVIVE || phase === BattlePhase.PAUSE) return false;
if (this._activeDrag != null || this._bagEntrancePlaying) return false;
return true;
}
/** 屏幕 UI 坐标 → 地图格索引 */
resolveMapGridAtUiPos(uiPos: { x: number; y: number }): number | null {
return this._resolveMapGridAt(uiPos);
}
/** 本机玩家所属出怪口编号(与万神幡召唤一致) */
resolveOwnSpawnPort(): number {
const ownerPlayerId = this._battle?.player?.playerId ?? 0;
return ownerPlayerId === 0 ? 2 : 1;
}
/** 屏幕 UI 坐标 → 己方出怪口编号;未命中则 null */
resolveOwnSpawnPortAtUiPos(uiPos: { x: number; y: number }): number | null {
const map = this._map;
if (!map?.spawnPointParent?.isValid) return null;
const port = this.resolveOwnSpawnPort();
const node = map.spawnPointParent.getChildByName(`spawnPoint_${port}`);
if (!node?.isValid) {
console.log('[BagBoard] 己方出怪口节点缺失', port);
return null;
}
return this._hitNode(node, uiPos) ? port : null;
}
/** 出怪口节点 */
getSpawnPointNode(spawnPort: number): Node | null {
const map = this._map;
const node = map?.spawnPointParent?.getChildByName(`spawnPoint_${spawnPort}`);
return node?.isValid ? node : null;
}
/** 出怪口世界坐标(拖拽拉线终点) */
getSpawnPointWorldPosition(spawnPort: number): Vec3 | null {
const node = this.getSpawnPointNode(spawnPort);
if (!node) return null;
return node.worldPosition.clone();
}
/** 屏幕 UI 坐标 → 敌方神将迷惑目标;无效则 null */
resolveConfuseHeroTargetAtUiPos(uiPos: { x: number; y: number }): CharmTarget | null {
const gridIndex = this._resolveEnemyCampOccupiedGridAtUiPos(uiPos);
if (gridIndex == null) return null;
return this._buildConfuseHeroTarget(gridIndex);
}
/** 幻魂镜目标拒绝原因;可施放则 null */
getConfuseHeroRejectReason(enemyGridIndex: number): ConfuseHeroRejectReason | null {
if (!this._isEnemyPlaceCampCell(enemyGridIndex)) return 'invalid';
const battle = this._battle;
if (!battle || battle.session.ended) return 'invalid';
if (battle.session.phase !== BattlePhase.COMBAT) return 'not_combat';
const enemyBag = AiBattleBridge.inst.aiBagSession;
if (!enemyBag?.merge.map.hasTile(enemyGridIndex)) return 'invalid';
if (!enemyBag.merge.isMultiCellCompositionAt(enemyGridIndex)) return 'single_glyph';
const target = this._buildConfuseHeroTarget(enemyGridIndex);
if (!target) return 'not_hero';
if (battle.charmController.isComposeKeyCharmed(target.composeKey)) return 'already_charmed';
if (!this.canApplyConfuseHeroTarget(target)) return 'not_hero';
return null;
}
/** 是否可对敌方神将施放幻魂镜 */
canApplyConfuseHeroTarget(target: CharmTarget): boolean {
const battle = this._battle;
if (!battle || battle.session.ended) return false;
if (battle.session.phase !== BattlePhase.COMBAT) return false;
if (battle.charmController.isComposeKeyCharmed(target.composeKey)) return false;
const enemyPlayer = battle.getPlayer(this._resolveEnemyPlayerId());
if (!enemyPlayer) return false;
const comp = enemyPlayer.compositions.find(c => c.gridIndices.includes(target.anchorGridIndex)) ?? null;
if (!comp || !isHeroActorWeapon1(comp.weapon1Id) || comp.gridIndices.length < 2) return false;
const session = battle.session;
if (!session.world.isAlive(target.entityId)) return false;
const abilities = session.world.getComponent(target.entityId, AbilitySetComp);
if (!abilities?.abilityIds.length) return false;
return !!getTowerLoadoutService().getLoadout(target.entityId)?.weapon1Id;
}
/** 预览幻魂镜落点(敌方神将多格绿/红 select) */
setConfuseHeroDropPreviewAtUiPos(uiPos: { x: number; y: number }): void {
this._clearBattleBuffSkillPreview();
const gridIndex = this._resolveEnemyCampOccupiedGridAtUiPos(uiPos);
if (gridIndex == null) return;
const reason = this.getConfuseHeroRejectReason(gridIndex);
if (reason == null) {
const target = this._buildConfuseHeroTarget(gridIndex);
if (!target) return;
for (let i = 0; i < target.gridIndices.length; i++) {
const idx = target.gridIndices[i];
this._confuseHeroPreviewGridIndices.push(idx);
this._applyMapGridSelectPreview(idx, true);
}
return;
}
this._confuseHeroPreviewGridIndices.push(gridIndex);
this._applyMapGridSelectPreview(gridIndex, false);
}
/** 幻魂镜表现挂点:己方 _mapGrids,敌方 AiMapTextHost */
getTextGridForConfuseHeroTarget(gridIndex: number): TextGrid | null {
return this._resolveMapTextGrid(gridIndex);
}
/** 幻魂镜循环层挂点:HeroActor 上的 battleHero 合成预制体 */
resolveConfuseHeroVisualSkin(target: CharmTarget): HeroSkin | null {
const battle = this._battle;
if (!battle) return null;
for (let playerId = 0; playerId <= 1; playerId++) {
const player = battle.getPlayer(playerId);
if (!player) continue;
const comp = player.compositions.find(
c => composeKey(c) === target.composeKey || c.gridIndices.includes(target.anchorGridIndex),
) ?? null;
if (!comp || comp.gridIndices.length < 2) continue;
const skin = HeroActorHost.instance.getVisualByComposition(comp);
if (skin?.displayNode?.isValid) return skin;
}
return null;
}
/** 敌方神将组合中心世界坐标 */
getConfuseHeroTargetWorldCenter(target: CharmTarget): Vec3 | null {
for (let i = 0; i < target.gridIndices.length; i++) {
const textGrid = this._resolveMapTextGrid(target.gridIndices[i]);
if (textGrid?.node?.isValid) {
return textGrid.resolveBattleBuffFxWorldPosition();
}
}
const textGrid = this._resolveMapTextGrid(target.anchorGridIndex);
if (textGrid?.node?.isValid) {
return textGrid.resolveBattleBuffFxWorldPosition();
}
const map = this._map;
if (!map) return null;
const sum = new Vec3();
let count = 0;
for (let i = 0; i < target.gridIndices.length; i++) {
const grid = map.getMapGridByIndex(target.gridIndices[i]);
if (!grid?.node?.isValid) continue;
sum.add(grid.node.worldPosition);
count++;
}
if (count <= 0) {
const fallback = map.getMapGridByIndex(target.anchorGridIndex);
return fallback?.node?.worldPosition.clone() ?? null;
}
return sum.multiplyScalar(1 / count);
}
/** 屏幕 UI 坐标 → 敌方 occupied 格(不论是否可偷) */
resolveStealGlyphEnemyGridAtUiPos(uiPos: { x: number; y: number }): number | null {
return this._resolveEnemyCampOccupiedGridAtUiPos(uiPos);
}
/** 屏幕 UI 坐标 → 敌方可偷取字块格;无效则 null */
resolveStealGlyphTargetAtUiPos(uiPos: { x: number; y: number }): number | null {
const gridIndex = this._resolveEnemyCampOccupiedGridAtUiPos(uiPos);
if (gridIndex == null) return null;
if (this.getStealGlyphRejectReason(gridIndex) != null) return null;
return gridIndex;
}
/** 妙手神偷目标拒绝原因;可偷则 null */
getStealGlyphRejectReason(enemyGridIndex: number): StealGlyphRejectReason | null {
if (!this._isEnemyPlaceCampCell(enemyGridIndex)) return 'invalid';
const bridge = AiBattleBridge.inst;
const enemyBag = bridge.aiBagSession;
if (!enemyBag) return 'invalid';
if (!enemyBag.merge.map.hasTile(enemyGridIndex)) return 'invalid';
if (enemyBag.merge.isMultiCellCompositionAt(enemyGridIndex)) return 'composed';
if (enemyBag.merge.canApplyStealGlyphMap(enemyGridIndex) != null) return 'invalid';
if (!this._bagSession?.canReceiveStolenTile()) return 'no_space';
return null;
}
/** 是否可对敌方格施放妙手神偷 */
canApplyStealGlyphAt(enemyGridIndex: number): boolean {
return this.getStealGlyphRejectReason(enemyGridIndex) == null;
}
/** 妙手神偷落点(优先地图空 Put) */
resolveStealReceivePlacement(): StealGlyphReceivePlacement | null {
return this._bagSession?.resolveStealReceivePlacement() ?? null;
}
/** 妙手神偷落点世界坐标 */
getStealReceiveWorldPosition(placement: StealGlyphReceivePlacement): Vec3 | null {
if (placement.kind === 'map') return this.getMapGridWorldPosition(placement.gridIndex);
return this.getBagSlotWorldPosition(placement.slotIndex);
}
/** 妙手神偷落点挂接父节点 */
getStealReceiveAttachParent(placement: StealGlyphReceivePlacement): Node | null {
if (placement.kind === 'map') {
return this._mapPlaceAttach(placement.gridIndex).attachParent;
}
return this._bagSlotNodeAt(placement.slotIndex) ?? null;
}
/** 预览妙手神偷落点(敌方字块绿/红 select) */
setStealGlyphDropPreviewAtUiPos(uiPos: { x: number; y: number }): void {
this._clearBattleBuffSkillPreview();
const gridIndex = this._resolveEnemyCampOccupiedGridAtUiPos(uiPos);
if (gridIndex == null) return;
const mg = this._map?.getMapGridByIndex(gridIndex);
if (!mg?.node?.isValid) return;
this._battleBuffPreviewIndex = gridIndex;
const reason = this.getStealGlyphRejectReason(gridIndex);
if (reason == null) mg.isSelectStatus = true;
else mg.isRedSelectStatus = true;
}
/** 摘除敌方 AI 地图字块视图(不销毁) */
detachEnemyMapTextGrid(gridIndex: number): TextGrid | null {
return AiBattleBridge.inst.detachAiMapTextGrid(gridIndex);
}
/**
* 妙手神偷演出:敌方字块离槽挂到飞行层。
* @returns 字块当前世界坐标;失败返回 null
*/
liftEnemyMapTextGridForSteal(grid: TextGrid, flyLayer: Node): Vec3 | null {
if (!grid?.node?.isValid || !flyLayer?.isValid) return null;
if (!flyLayer.active) flyLayer.active = true;
grid.enableDrag(false);
grid.setMapClickHandler(null);
grid.isSelectStatus = false;
grid.isRedSelectStatus = false;
grid.preparePlaceFlightVisual();
const wp = grid.node.worldPosition.clone();
grid.node.parent = flyLayer;
grid.node.setWorldPosition(wp);
grid.node.setSiblingIndex(flyLayer.children.length - 1);
return wp;
}
/** 清理敌方被偷格上的残留字块(HeroActor 由 DeploySync 对账移除) */
purgeEnemyStealSourceCell(gridIndex: number, keepGrid: TextGrid | null): void {
const mg = this._map?.getMapGridByIndex(gridIndex);
if (mg) {
mg.isSelectStatus = false;
mg.isRedSelectStatus = false;
}
const live = this._mapGridOnCell(gridIndex);
if (live?.isValid && live !== keepGrid) this._forceRecycleTextGrid(live);
let enemy: TextGrid | null = null;
this.forEachEnemyMapTextGrid((g, idx) => {
if (idx === gridIndex) enemy = g;
});
if (enemy?.isValid && enemy !== keepGrid) this._forceRecycleTextGrid(enemy);
}
/** 读取敌方地图字块视图(不摘除托管) */
getEnemyMapTextGrid(gridIndex: number): TextGrid | null {
let found: TextGrid | null = null;
this.forEachEnemyMapTextGrid((g, idx) => {
if (idx === gridIndex) found = g;
});
return found;
}
/**
* 遍历敌方 AI 地图字块。
* @param visitor 回调
*/
forEachEnemyMapTextGrid(visitor: (grid: TextGrid, gridIndex: number) => void): void {
AiBattleBridge.inst.forEachAiMapTextGrid(visitor);
}
/** 从敌方 merge 提取字块并同步 AI 盘面 */
extractStealGlyphFromEnemy(enemyGridIndex: number): MapTileRecord | null {
const bridge = AiBattleBridge.inst;
const enemyBag = bridge.aiBagSession;
if (!enemyBag) return null;
const result = enemyBag.merge.extractMapTileForSteal(enemyGridIndex);
if ('reason' in result) return null;
bridge.aiDeploySync?.reconcileFromMerge('stealGlyph');
bridge.notifyAiBoardChanged();
return result.tile;
}
/**
* 妙手神偷:写入己方 merge 盘面。
* @param tile 偷取字块
* @param placement 落点
*/
applyStealGlyphGrant(tile: MapTileRecord, placement: StealGlyphReceivePlacement): boolean {
const session = this._bagSession;
if (!session) return false;
if (placement.kind === 'map') {
const reason = session.merge.grantStolenTileOnPutGrid(placement.gridIndex, tile);
if (reason) return false;
session.syncMapCellVisualsFromLogic();
return true;
}
return session.merge.grantStolenTileInBagSlot(placement.slotIndex, tile) == null;
}
/** 妙手神偷飞行结束:提交字块视图到己方槽/格 */
commitStolenTextGridView(grid: TextGrid, placement: StealGlyphReceivePlacement): void {
grid.setAiMapLite(false);
if (placement.kind === 'map') {
this._commitMapTextGridView(placement.gridIndex, grid);
const mg = this._map?.getMapGridByIndex(placement.gridIndex);
mg?.playFallEffect?.();
} else {
this._commitBagTextGridView(placement.slotIndex, grid);
}
this._deploySync?.reconcileFromMerge('stealGlyph');
grid.finishPlaceFlightVisual();
grid.syncPresentationVisibility();
this._refreshCanUpgradeHints();
this.clearBattleBuffDropPreview();
GEvent.Ins.emit(GEvent.UpdateBestPlace);
BattleAudio.playPlaceGrid();
}
/** 读取本机地图字块视图(不摘除) */
getOwnMapTextGrid(gridIndex: number): TextGrid | null {
return this._mapGridOnCell(gridIndex);
}
/**
* AI 妙手神偷:摘除本机地图字块视图并记入飞行列表(防 extract 触发 rebuild 回收)。
* @param gridIndex 玩家地图格
*/
detachOwnMapTextGridForSteal(gridIndex: number): TextGrid | null {
const grid = this.getOwnMapTextGrid(gridIndex);
if (!grid?.node?.isValid) return null;
this._grids.detachForFlight(grid, { kind: 'map', gridIndex });
return grid;
}
/**
* AI 妙手神偷演出:本机字块离槽挂到飞行层。
* @returns 字块当前世界坐标;失败返回 null
*/
liftOwnMapTextGridForSteal(grid: TextGrid, flyLayer: Node): Vec3 | null {
return this.liftEnemyMapTextGridForSteal(grid, flyLayer);
}
/**
* 神偷演出开始:推迟 session rebuild,避免 ComposeChanged 冲掉飞行字块。
*/
beginStealGlyphPresentation(): void {
this._mapDropAnimInflight++;
}
/**
* 神偷演出结束:恢复 rebuild 并冲刷挂起重建。
*/
endStealGlyphPresentation(): void {
this._mapDropAnimInflight = Math.max(0, this._mapDropAnimInflight - 1);
this._flushPendingSessionRebuild();
this._flushPendingBagRebuild();
}
/** 清理本机被偷格上的残留字块 */
purgeOwnStealSourceCell(gridIndex: number, keepGrid: TextGrid | null): void {
this.purgeEnemyStealSourceCell(gridIndex, keepGrid);
}
/** 从本机 merge 提取字块并同步玩家盘面(AI 神偷) */
extractStealGlyphFromOwn(playerGridIndex: number): MapTileRecord | null {
const session = this._bagSession;
if (!session) return null;
const result = session.merge.extractMapTileForSteal(playerGridIndex);
if ('reason' in result) return null;
this._deploySync?.reconcileFromMerge('aiStealGlyph');
return result.tile;
}
/**
* AI 妙手神偷落点世界坐标(落背包时飞向 BattleGame ui_otherBag)。
* @param placement AI 落点
*/
getAiStealReceiveWorldPosition(placement: StealGlyphReceivePlacement): Vec3 | null {
if (placement.kind === 'map') return this.getMapGridWorldPosition(placement.gridIndex);
const anchor = this._otherBagAnchor;
if (!anchor?.isValid) {
console.log('[StealGlyph] ui_otherBag 缺失,AI 背包落点无世界坐标', placement.slotIndex);
return null;
}
return anchor.worldPosition.clone();
}
/** AI 妙手神偷落点挂接父节点;背包无视图则 null */
getAiStealReceiveAttachParent(placement: StealGlyphReceivePlacement): Node | null {
if (placement.kind === 'map') {
return this._mapPlaceAttach(placement.gridIndex).attachParent;
}
return null;
}
/**
* AI 妙手神偷:写入 AI merge 盘面。
* @param tile 偷取字块
* @param placement AI 落点
*/
applyAiStealGlyphGrant(tile: MapTileRecord, placement: StealGlyphReceivePlacement): boolean {
const bridge = AiBattleBridge.inst;
const aiBag = bridge.aiBagSession;
if (!aiBag) return false;
if (placement.kind === 'map') {
const reason = aiBag.merge.grantStolenTileOnPutGrid(placement.gridIndex, tile);
if (reason) return false;
aiBag.syncMapCellVisualsFromLogic();
return true;
}
return aiBag.merge.grantStolenTileInBagSlot(placement.slotIndex, tile) == null;
}
/**
* AI 妙手神偷飞行结束:提交到 AI 地图或回收(背包无头无视图)。
* @param grid 飞行字块
* @param placement AI 落点
*/
commitAiStolenTextGridView(grid: TextGrid, placement: StealGlyphReceivePlacement): void {
const bridge = AiBattleBridge.inst;
this._grids.removeFromFlight(grid);
if (placement.kind === 'map') {
bridge.attachAiStolenMapTextGrid(placement.gridIndex, grid);
const mg = this._map?.getMapGridByIndex(placement.gridIndex);
mg?.playFallEffect?.();
grid.finishPlaceFlightVisual();
grid.syncPresentationVisibility();
} else {
this.releaseRecyclePresentationGrid(grid);
}
bridge.aiDeploySync?.reconcileFromMerge('aiStealGlyph');
bridge.notifyAiBoardChanged();
BattleAudio.playPlaceGrid();
}
/** 从索引表摘除字块(演出离槽,不销毁节点) */
purgeGridFromIndices(grid: TextGrid): void {
this._grids.purgeGridFromIndices(grid);
}
/** 屏幕 UI 坐标 → 地图格索引(仅含已放置字块的格;背包区域不算,供封神榜等仅地图 buff) */
resolveMapOccupiedGridAtUiPos(uiPos: { x: number; y: number }): number | null {
if (!this._map || !this._bagSession) return null;
if (this._resolveBagSlotAt(uiPos) != null) return null;
return this._resolveMapOccupiedGridAt(uiPos);
}
/** 屏幕 UI 坐标 → 地图 occupied 格(不含背包区域过滤逻辑) */
private _resolveMapOccupiedGridAt(uiPos: { x: number; y: number }): number | null {
if (!this._map || !this._bagSession) return null;
const grids = this._map.mapGrids;
if (!Array.isArray(grids)) return null;
const p = new Vec2(uiPos.x, uiPos.y);
let bestIndex: number | null = null;
let bestDist = Infinity;
for (const mg of grids) {
if (!mg?.node?.isValid) continue;
if (!this._bagSession.merge.map.hasTile(mg.Index)) continue;
const ut = mg.node.getComponent(UITransform);
if (!ut) continue;
const rect = ut.getBoundingBoxToWorld();
if (!rect.contains(p)) continue;
const dist = Vec2.distance(p, rect.center);
if (dist < bestDist) {
bestDist = dist;
bestIndex = mg.Index;
}
}
return bestIndex;
}
/**
* 拖到字块类主动 buff 通用落点。
* 可:多格组合字已合成武将、type1 普通单字;不可:type2 未合成英雄散字。
*/
canApplyBattleBuffSkillDropTarget(target: BattleBuffSkillDropTarget): boolean {
if (!this._bagSession) return false;
const merge = this._bagSession.merge;
if (target.kind === 'map') {
return merge.canApplyBattleBuffSkillDropMap(target.gridIndex) == null;
}
return merge.canApplyBattleBuffSkillDropBag(target.slotIndex) == null;
}
/** 主动 buff 是否可升级目标 */
canApplyBattleBuffLevelUpTarget(target: BattleBuffSkillDropTarget, levelDelta: number): boolean {
if (!this._bagSession || levelDelta <= 0) return false;
const battle = this._battle;
const bagSession = this._bagSession;
if (target.kind === 'map' && battle && shouldBattleBuffLevelUpViaExpFill(battle, bagSession, target)) {
return canApplyBattleBuffLevelUpViaExpFill(battle, bagSession, target.gridIndex, levelDelta);
}
const merge = bagSession.merge;
if (target.kind === 'map') {
return merge.canApplyBattleBuffUnitLevelUp(target.gridIndex, levelDelta) == null;
}
return merge.canApplyBattleBuffUnitLevelUpBag(target.slotIndex, levelDelta) == null;
}
/** 主动 buff 升级;成功时 emit MapUpgraded / BagMerged */
applyBattleBuffLevelUpTarget(target: BattleBuffSkillDropTarget, levelDelta: number): boolean {
if (!this._bagSession || levelDelta <= 0) return false;
const battle = this._battle;
const bagSession = this._bagSession;
this._suppressDefaultUpgradeFx = true;
try {
if (target.kind === 'map' && battle && shouldBattleBuffLevelUpViaExpFill(battle, bagSession, target)) {
return applyBattleBuffLevelUpViaExpFill(battle, bagSession, target.gridIndex);
}
const merge = bagSession.merge;
const reason: PlaceRejectReason | null = target.kind === 'map'
? merge.applyBattleBuffUnitLevelUp(target.gridIndex, levelDelta)
: merge.applyBattleBuffUnitLevelUpBag(target.slotIndex, levelDelta);
return reason == null;
} finally {
this._suppressDefaultUpgradeFx = false;
}
}
/** @deprecated 使用 canApplyBattleBuffLevelUpTarget */
canApplyBattleBuffLevelUp(gridIndex: number, levelDelta: number): boolean {
return this.canApplyBattleBuffLevelUpTarget({ kind: 'map', gridIndex }, levelDelta);
}
/** @deprecated 使用 applyBattleBuffLevelUpTarget */
applyBattleBuffLevelUp(gridIndex: number, levelDelta: number): boolean {
return this.applyBattleBuffLevelUpTarget({ kind: 'map', gridIndex }, levelDelta);
}
/** 是否可对地图格施放授仙箓 */
canApplyBattleBuffRandomLevel(gridIndex: number): boolean {
if (!this._bagSession) return false;
return this._bagSession.merge.canApplyBattleBuffRandomLevel(gridIndex) == null;
}
/** 是否可对背包槽施放授仙箓(普通 type1 单字;type2 未合成散字不可) */
canApplyBattleBuffRandomLevelBag(slotIndex: number): boolean {
if (!this._bagSession) return false;
return this._bagSession.merge.canApplyBattleBuffRandomLevelBag(slotIndex) == null;
}
/** 授仙箓落点是否合法 */
canApplyBattleBuffRandomLevelTarget(target: BattleBuffSkillDropTarget): boolean {
if (target.kind === 'map') return this.canApplyBattleBuffRandomLevel(target.gridIndex);
return this.canApplyBattleBuffRandomLevelBag(target.slotIndex);
}
/** 屏幕坐标 → 主动 skill 落点(优先背包字块,其次地图 occupied 格) */
resolveBattleBuffSkillDropTargetAtUiPos(uiPos: { x: number; y: number }): BattleBuffSkillDropTarget | null {
const bagSlotFromGrid = this._resolveBagSlotAtUiPos(uiPos);
if (bagSlotFromGrid != null && this._bagSession?.getBagSlots()[bagSlotFromGrid]) {
return { kind: 'bag', slotIndex: bagSlotFromGrid };
}
const gridIndex = this._resolveMapOccupiedGridAt(uiPos);
if (gridIndex != null) return { kind: 'map', gridIndex };
return null;
}
/** @deprecated 使用 resolveBattleBuffSkillDropTargetAtUiPos */
resolveBattleBuffRandomLevelTargetAtUiPos(uiPos: { x: number; y: number }): BattleBuffSkillDropTarget | null {
return this.resolveBattleBuffSkillDropTargetAtUiPos(uiPos);
}
/** 授仙箓:随机升/降级;成功返回等级变化(0 无变化),失败 null */
applyBattleBuffRandomLevelTarget(
target: BattleBuffSkillDropTarget,
levelUpProbsWan: readonly number[],
): number | null {
if (!this._bagSession || levelUpProbsWan.length < 4) return null;
const battle = this._battle;
if (!battle) {
console.log('[CocosBagBoardView] 授仙箓缺少 battle rng');
return null;
}
const rollWan = battle.session.rng.int(0, 9999);
this._suppressDefaultUpgradeFx = true;
try {
return applyBattleBuffRandomLevelWithExpFill(
battle,
this._bagSession,
target,
levelUpProbsWan,
rollWan,
);
} finally {
this._suppressDefaultUpgradeFx = false;
}
}
/** 是否可对地图格施放文昌笔 */
canApplyBattleBuffRewriteGlyphMap(gridIndex: number): boolean {
if (!this._bagSession) return false;
if (this._bagSession.merge.canApplyBattleBuffRewriteGlyphMap(gridIndex) != null) return false;
const tile = this._bagSession.merge.map.getTile(gridIndex);
if (!tile) return false;
return this._bagSession.canRollRewriteGlyph(tile.weapon2Id);
}
/** 是否可对背包槽施放文昌笔 */
canApplyBattleBuffRewriteGlyphBag(slotIndex: number): boolean {
if (!this._bagSession) return false;
if (this._bagSession.merge.canApplyBattleBuffRewriteGlyphBag(slotIndex) != null) return false;
const slot = this._bagSession.getBagSlots()[slotIndex];
if (!slot) return false;
return this._bagSession.canRollRewriteGlyph(slot.weapon2Id);
}
/** 文昌笔落点是否合法 */
canApplyBattleBuffRewriteGlyphTarget(target: BattleBuffSkillDropTarget): boolean {
if (target.kind === 'map') return this.canApplyBattleBuffRewriteGlyphMap(target.gridIndex);
return this.canApplyBattleBuffRewriteGlyphBag(target.slotIndex);
}
/** 文昌笔:按召唤字池改字 */
applyBattleBuffRewriteGlyphTarget(target: BattleBuffSkillDropTarget): boolean {
if (!this._bagSession) return false;
const excludeId = this._resolveRewriteExcludeWeapon2Id(target);
if (excludeId == null) return false;
const rolled = this._bagSession.rollRewriteGlyph(excludeId);
if (!rolled) return false;
const merge = this._bagSession.merge;
const reason = target.kind === 'map'
? merge.applyBattleBuffRewriteGlyphMap(target.gridIndex, rolled.weapon2Id)
: merge.applyBattleBuffRewriteGlyphBag(target.slotIndex, rolled.weapon2Id);
return reason == null;
}
/** 预览攻速符落点(绿/红 select) */
setBattleBuffAttackSpeedDropPreview(target: BattleBuffSkillDropTarget | null, buffId: number): void {
this._clearBattleBuffSkillPreview();
if (target == null) return;
if (target.kind === 'bag') {
this._battleBuffPreviewBagSlot = target.slotIndex;
const slot = this._bagSession?.getBagSlots()[target.slotIndex];
if (!slot) return;
if (this.canApplyBattleBuffAttackSpeedTarget(target, buffId)) {
this._setBagSlotSelect(target.slotIndex, true);
return;
}
const tg = this._slotGrids.get(target.slotIndex);
if (tg?.node?.isValid) tg.isRedSelectStatus = true;
return;
}
const normalized = this._normalizeBattleBuffSkillDropTarget(target);
const alreadyUsed = this.isAttackSpeedTargetAlreadyUsed(target, buffId);
const canApply = this.canApplyBattleBuffAttackSpeedTarget(target, buffId);
this._applyBattleBuffMapTileDropPreview(
target.gridIndex,
canApply,
alreadyUsed && this.canApplyBattleBuffSkillDropTarget(normalized),
);
}
/** 攻速符:落点是否可施放(通用字块落点 + 已上场攻击单位 / 背包预绑定) */
canApplyBattleBuffAttackSpeedTarget(target: BattleBuffSkillDropTarget, buffId: number): boolean {
const battle = this._battle;
if (!battle?.player) return false;
const normalized = this._normalizeBattleBuffSkillDropTarget(target);
if (getBattleGameGlobalAttackSpeedHost().isTargetUsed(
buffId,
normalized,
battle.session,
battle.player,
this._map,
)) return false;
if (!this.canApplyBattleBuffSkillDropTarget(normalized)) return false;
if (normalized.kind === 'map') return this.canApplyBattleBuffAttackSpeedMap(normalized.gridIndex);
return true;
}
/** 攻速符:同 buffId 是否已对该目标单位施放过(用于重复使用提示) */
isAttackSpeedTargetAlreadyUsed(target: BattleBuffSkillDropTarget, buffId: number): boolean {
if (buffId <= 0) return false;
const battle = this._battle;
if (!battle?.player) return false;
const normalized = this._normalizeBattleBuffSkillDropTarget(target);
if (!this.canApplyBattleBuffSkillDropTarget(normalized)) return false;
return getBattleGameGlobalAttackSpeedHost().isTargetUsed(
buffId,
normalized,
battle.session,
battle.player,
this._map,
);
}
/** 攻速符:地图落点是否对应已部署攻击单位(多格组合字已合成武将 / type1 单字塔) */
canApplyBattleBuffAttackSpeedMap(gridIndex: number): boolean {
const battle = this._battle;
if (!battle?.player || !this._map) return false;
return getBattleGameGlobalAttackSpeedHost().resolveMapTargetEntityId(
battle.session,
battle.player,
this._map,
gridIndex,
) != null;
}
/** 攻速符:施放指定单位攻速加成 */
applyBattleBuffAttackSpeedTarget(target: BattleBuffSkillDropTarget, skillSlot: number, buffId: number, bonusWan: number): boolean {
if (!this.canApplyBattleBuffAttackSpeedTarget(target, buffId)) return false;
const battle = this._battle;
if (!battle?.player) return false;
const normalized = this._normalizeBattleBuffSkillDropTarget(target);
return getBattleGameGlobalAttackSpeedHost().tryActivate(
battle.session,
battle.player,
this._map,
buffId,
skillSlot,
normalized,
bonusWan,
);
}
/** 背包字块上场后,尝试绑定待生效的攻速符 */
tryBindBagAttackSpeedAfterPlace(bagSlot: number, gridIndex: number): void {
const battle = this._battle;
if (!battle?.player || !this._map) return;
getBattleGameGlobalAttackSpeedHost().onBagPlacedOnMap(
battle.session,
battle.player,
this._map,
bagSlot,
gridIndex,
);
}
/** 预览千里眼落点(绿/红 select,仅远程攻击单位可用) */
setBattleBuffAttackRangeDropPreview(target: BattleBuffSkillDropTarget | null, buffId: number): void {
this._clearBattleBuffSkillPreview();
if (target == null) return;
if (target.kind === 'bag') {
this._battleBuffPreviewBagSlot = target.slotIndex;
const slot = this._bagSession?.getBagSlots()[target.slotIndex];
if (!slot) return;
if (this.canApplyBattleBuffAttackRangeTarget(target, buffId)) {
this._setBagSlotSelect(target.slotIndex, true);
return;
}
const tg = this._slotGrids.get(target.slotIndex);
if (tg?.node?.isValid) tg.isRedSelectStatus = true;
return;
}
const normalized = this._normalizeBattleBuffSkillDropTarget(target);
const alreadyUsed = this.isAttackRangeTargetAlreadyUsed(target, buffId);
const canApply = this.canApplyBattleBuffAttackRangeTarget(target, buffId);
this._applyBattleBuffMapTileDropPreview(
target.gridIndex,
canApply,
alreadyUsed && this.canApplyBattleBuffSkillDropTarget(normalized),
);
}
/** 千里眼:落点是否可施放(通用字块落点 + 远程攻击单位;该 buffId 未对该落点用过) */
canApplyBattleBuffAttackRangeTarget(target: BattleBuffSkillDropTarget, buffId: number): boolean {
const battle = this._battle;
if (!battle?.player) return false;
const normalized = this._normalizeBattleBuffSkillDropTarget(target);
const hostTarget = normalized.kind === 'bag'
? { kind: 'bag' as const, slotIndex: normalized.slotIndex, bagWeapon1Id: this._resolveBagSlotWeapon1Id(normalized.slotIndex) ?? undefined }
: normalized;
if (getBattleGameGlobalAttackRangeHost().isTargetUsed(
buffId,
hostTarget,
battle.session,
battle.player,
this._map,
)) return false;
if (!this.canApplyBattleBuffSkillDropTarget(normalized)) return false;
if (normalized.kind === 'map') return this.canApplyBattleBuffAttackRangeMap(normalized.gridIndex);
return this.canApplyBattleBuffAttackRangeBag(normalized.slotIndex);
}
/** 千里眼:背包落点是否对应远程攻击单位(Hero.type===2) */
canApplyBattleBuffAttackRangeBag(slotIndex: number): boolean {
const weapon1Id = this._resolveBagSlotWeapon1Id(slotIndex);
if (weapon1Id == null || weapon1Id <= 0) return false;
return BattleGameGlobalAttackRangeHost.isRemoteHero(weapon1Id);
}
/** 千里眼:地图落点是否对应已部署远程攻击单位(多格组合字已合成武将 / type1 远程单字塔) */
canApplyBattleBuffAttackRangeMap(gridIndex: number): boolean {
const weapon1Id = this._resolveMapTileWeapon1Id(gridIndex);
if (weapon1Id == null || !BattleGameGlobalAttackRangeHost.isRemoteHero(weapon1Id)) return false;
const battle = this._battle;
if (!battle?.player || !this._map) return false;
return getBattleGameGlobalAttackRangeHost().resolveMapTargetEntityId(
battle.session,
battle.player,
this._map,
gridIndex,
) != null;
}
/** 千里眼:地图落点是否对应已部署近战攻击单位(用于失败提示) */
isMapTargetMeleeUnit(gridIndex: number): boolean {
const battle = this._battle;
if (!battle?.player || !this._map) return false;
return getBattleGameGlobalAttackRangeHost().isMapTargetMeleeUnit(
battle.session,
battle.player,
this._map,
gridIndex,
);
}
/** 千里眼:落点是否为近战攻击单位(背包槽 / 地图已部署 / 地图字块) */
isAttackRangeTargetMeleeUnit(target: BattleBuffSkillDropTarget): boolean {
if (target.kind === 'bag') {
const weapon1Id = this._resolveBagSlotWeapon1Id(target.slotIndex);
if (weapon1Id == null || weapon1Id <= 0) return false;
return !BattleGameGlobalAttackRangeHost.isRemoteHero(weapon1Id);
}
if (this.isMapTargetMeleeUnit(target.gridIndex)) return true;
const weapon1Id = this._resolveMapTileWeapon1Id(target.gridIndex);
if (weapon1Id == null || weapon1Id <= 0) return false;
return !BattleGameGlobalAttackRangeHost.isRemoteHero(weapon1Id);
}
/** 千里眼:落点是否对应远程攻击单位(含多格合成武将) */
isAttackRangeTargetRemoteUnit(target: BattleBuffSkillDropTarget): boolean {
if (target.kind === 'bag') {
return this.canApplyBattleBuffAttackRangeBag(target.slotIndex);
}
const weapon1Id = this._resolveMapTileWeapon1Id(target.gridIndex);
if (weapon1Id != null && weapon1Id > 0 && BattleGameGlobalAttackRangeHost.isRemoteHero(weapon1Id)) {
return true;
}
const battle = this._battle;
if (!battle?.player || !this._map) return false;
return getBattleGameGlobalAttackRangeHost().resolveMapTargetEntityId(
battle.session,
battle.player,
this._map,
target.gridIndex,
) != null;
}
/** 千里眼:同 buffId 是否已对该远程落点施放过(用于重复使用提示) */
isAttackRangeTargetAlreadyUsed(target: BattleBuffSkillDropTarget, buffId: number): boolean {
if (buffId <= 0) return false;
const battle = this._battle;
if (!battle?.player) return false;
const normalized = this._normalizeBattleBuffSkillDropTarget(target);
const hostTarget = normalized.kind === 'bag'
? { kind: 'bag' as const, slotIndex: normalized.slotIndex, bagWeapon1Id: this._resolveBagSlotWeapon1Id(normalized.slotIndex) ?? undefined }
: normalized;
if (!getBattleGameGlobalAttackRangeHost().isTargetUsed(
buffId,
hostTarget,
battle.session,
battle.player,
this._map,
)) return false;
if (this.isAttackRangeTargetMeleeUnit(target)) return false;
return this.isAttackRangeTargetRemoteUnit(target);
}
/**
* 解析千里眼范围表现参数(须在 applyBattleBuffAttackRangeTarget 之前调用)。
* @returns 圆心与加成前后射程;无法解析时 null
*/
resolveAttackRangeBuffFxPreview(target: BattleBuffSkillDropTarget): AttackRangeBuffFxPreview | null {
const grid = this.getTextGridForDropTarget(target);
if (!grid?.node?.isValid) return null;
const index = target.kind === 'bag' ? target.slotIndex : target.gridIndex;
const center = this._resolveRangeCenterWorld({ kind: target.kind, index }, grid);
if (!center) return null;
const baseRangePx = this._resolveGridRangePx(grid);
if (!(baseRangePx > 0)) return null;
const weapon1Id = target.kind === 'bag'
? this._resolveBagSlotWeapon1Id(target.slotIndex)
: this._resolveMapTileWeapon1Id(target.gridIndex);
if (weapon1Id == null || weapon1Id <= 0) return null;
const hero = gg.data.table.getTableData<ITableHero>(TableNames.Hero, weapon1Id);
if (!hero) return null;
const bonusRangePx = Number(hero.atk_range) || 0;
if (!(bonusRangePx > 0)) return null;
return {
centerWorld: center,
baseRangePx,
endRangePx: baseRangePx + bonusRangePx,
};
}
/** 千里眼:0.5s 扩散攻击范围,停留 0.5s 后隐藏 */
playAttackRangeBuffRangeTween(preview: AttackRangeBuffFxPreview): void {
const map = this._map;
const cellA = map?.getMapGridByIndex(0);
const cellB = map?.getMapGridByIndex(1);
const cellSize = map?.mapGridSize ?? 0;
const posA = cellA?.node?.isValid ? cellA.node.worldPosition : null;
const posB = cellB?.node?.isValid ? cellB.node.worldPosition : null;
this._rangePresenter.animateRadiusAtWorldCenter(
preview.centerWorld,
preview.baseRangePx,
preview.endRangePx,
cellSize,
posA,
posB,
this._mapRoot?.parent ?? null,
);
}
/** 千里眼:施放指定远程单位射程加成 */
applyBattleBuffAttackRangeTarget(target: BattleBuffSkillDropTarget, skillSlot: number, buffId: number): boolean {
if (!this.canApplyBattleBuffAttackRangeTarget(target, buffId)) return false;
const battle = this._battle;
if (!battle?.player) return false;
const normalized = this._normalizeBattleBuffSkillDropTarget(target);
const hostTarget = normalized.kind === 'bag'
? { kind: 'bag' as const, slotIndex: normalized.slotIndex, bagWeapon1Id: this._resolveBagSlotWeapon1Id(normalized.slotIndex) ?? undefined }
: normalized;
return getBattleGameGlobalAttackRangeHost().tryActivate(
battle.session,
battle.player,
this._map,
buffId,
skillSlot,
hostTarget,
);
}
/** 背包字块上场后,尝试绑定待生效的千里眼 */
tryBindBagAttackRangeAfterPlace(bagSlot: number, gridIndex: number): void {
const battle = this._battle;
if (!battle?.player || !this._map) return;
getBattleGameGlobalAttackRangeHost().onBagPlacedOnMap(
battle.session,
battle.player,
this._map,
bagSlot,
gridIndex,
);
}
/** 预览文昌笔落点(绿/红 select) */
setBattleBuffRewriteGlyphDropPreview(target: BattleBuffSkillDropTarget | null): void {
this._clearBattleBuffSkillPreview();
if (target == null) return;
if (target.kind === 'bag') {
this._battleBuffPreviewBagSlot = target.slotIndex;
const slot = this._bagSession?.getBagSlots()[target.slotIndex];
if (!slot) return;
if (this.canApplyBattleBuffRewriteGlyphBag(target.slotIndex)) {
this._setBagSlotSelect(target.slotIndex, true);
return;
}
const tg = this._slotGrids.get(target.slotIndex);
if (tg?.node?.isValid) tg.isRedSelectStatus = true;
return;
}
this._battleBuffPreviewIndex = target.gridIndex;
const mg = this._map?.getMapGridByIndex(target.gridIndex);
if (!mg?.node?.isValid) return;
if (this.canApplyBattleBuffRewriteGlyphMap(target.gridIndex)) {
mg.isSelectStatus = true;
return;
}
if (this._bagSession?.merge.map.hasTile(target.gridIndex)) {
mg.isRedSelectStatus = true;
}
}
/** 文昌笔改字目标当前 weapon2Id */
private _resolveRewriteExcludeWeapon2Id(target: BattleBuffSkillDropTarget): number | null {
if (!this._bagSession) return null;
if (target.kind === 'map') {
return this._bagSession.merge.map.getTile(target.gridIndex)?.weapon2Id ?? null;
}
return this._bagSession.getBagSlots()[target.slotIndex]?.weapon2Id ?? null;
}
/** 背包槽对应 Hero.id(weapon1Id) */
private _resolveBagSlotWeapon1Id(slotIndex: number): number | null {
if (!this._bagSession) return null;
const slot = this._bagSession.getBagSlots()[slotIndex];
if (!slot) return null;
const provider = WeaponComposeConfigProvider.instance;
return slot.weapon1Id ?? provider.getWeapon1ByGroupSequence([slot.weapon2Id]);
}
/**
* 多格组合字已合成武将:任意占格 → 锚点格 gridIndices[0](去重 / ECS 查询一致)。
* @param target 原始落点
*/
private _normalizeBattleBuffSkillDropTarget(target: BattleBuffSkillDropTarget): BattleBuffSkillDropTarget {
if (target.kind !== 'map' || !this._bagSession) return target;
const compose = this._bagSession.merge.compositions.find(c => c.gridIndices.includes(target.gridIndex)) ?? null;
if (!compose || !this._bagSession.merge.isMultiCellCompositionAt(target.gridIndex)) return target;
return { kind: 'map', gridIndex: compose.gridIndices[0] };
}
/** 地图格对应 Hero.id(weapon1Id) */
private _resolveMapTileWeapon1Id(gridIndex: number): number | null {
if (!this._bagSession) return null;
const tile = this._bagSession.merge.map.getTile(gridIndex);
if (!tile) return null;
const compose = this._bagSession.merge.compositions.find(c => c.gridIndices.includes(gridIndex)) ?? null;
if (compose) return compose.weapon1Id;
const provider = WeaponComposeConfigProvider.instance;
return tile.weapon1Id ?? provider.getWeapon1ByGroupSequence([tile.weapon2Id]);
}
/** @deprecated 使用 applyBattleBuffRandomLevelTarget */
applyBattleBuffRandomLevel(gridIndex: number, levelUpProbsWan: readonly number[]): boolean {
return this.applyBattleBuffRandomLevelTarget(
{ kind: 'map', gridIndex },
levelUpProbsWan,
) != null;
}
/** 预览授仙箓落点(绿/红 select) */
setBattleBuffRandomLevelDropPreview(target: BattleBuffSkillDropTarget | null): void {
this._clearBattleBuffSkillPreview();
if (target == null) return;
if (target.kind === 'bag') {
this._battleBuffPreviewBagSlot = target.slotIndex;
const slot = this._bagSession?.getBagSlots()[target.slotIndex];
if (!slot) return;
if (this.canApplyBattleBuffRandomLevelBag(target.slotIndex)) {
this._setBagSlotSelect(target.slotIndex, true);
return;
}
const tg = this._slotGrids.get(target.slotIndex);
if (tg?.node?.isValid) tg.isRedSelectStatus = true;
return;
}
this._battleBuffPreviewIndex = target.gridIndex;
const mg = this._map?.getMapGridByIndex(target.gridIndex);
if (!mg?.node?.isValid) return;
if (this.canApplyBattleBuffRandomLevel(target.gridIndex)) {
mg.isSelectStatus = true;
return;
}
if (this._bagSession?.merge.map.hasTile(target.gridIndex)) {
mg.isRedSelectStatus = true;
}
}
/** 预览封神榜落点(绿/红 select) */
setBattleBuffLevelUpDropPreview(target: BattleBuffSkillDropTarget | null, levelDelta: number): void {
this._clearBattleBuffSkillPreview();
if (target == null) return;
if (target.kind === 'bag') {
this._battleBuffPreviewBagSlot = target.slotIndex;
const slot = this._bagSession?.getBagSlots()[target.slotIndex];
if (!slot) return;
if (this.canApplyBattleBuffLevelUpTarget(target, levelDelta)) {
this._setBagSlotSelect(target.slotIndex, true);
return;
}
const tg = this._slotGrids.get(target.slotIndex);
if (tg?.node?.isValid) tg.isRedSelectStatus = true;
return;
}
this._battleBuffPreviewIndex = target.gridIndex;
const mg = this._map?.getMapGridByIndex(target.gridIndex);
if (!mg?.node?.isValid) return;
if (this.canApplyBattleBuffLevelUpTarget(target, levelDelta)) {
mg.isSelectStatus = true;
return;
}
if (this._bagSession?.merge.map.hasTile(target.gridIndex)) {
mg.isRedSelectStatus = true;
}
}
/** 地图 occupied 格 skill 落点预览(优先 TextGrid,避免被字块遮挡) */
private _applyBattleBuffMapTileDropPreview(gridIndex: number, canApply: boolean, forceRed = false): void {
this._battleBuffPreviewIndex = gridIndex;
const showRed = forceRed || (!canApply && (this._bagSession?.merge.map.hasTile(gridIndex) ?? false));
const textGrid = this._mapGrids.get(gridIndex);
if (textGrid?.node?.isValid) {
if (canApply) textGrid.isSelectStatus = true;
else if (showRed) textGrid.isRedSelectStatus = true;
return;
}
const mg = this._map?.getMapGridByIndex(gridIndex);
if (!mg?.node?.isValid) return;
if (canApply) mg.isSelectStatus = true;
else if (showRed) mg.isRedSelectStatus = true;
}
/** 清除幻魂镜多格预览 */
private _clearConfuseHeroDropPreview(): void {
for (let i = 0; i < this._confuseHeroPreviewGridIndices.length; i++) {
const idx = this._confuseHeroPreviewGridIndices[i];
const mg = this._map?.getMapGridByIndex(idx);
if (mg?.node?.isValid) {
mg.isSelectStatus = false;
mg.isRedSelectStatus = false;
}
const textGrid = this._resolveMapTextGrid(idx);
if (textGrid?.node?.isValid) {
textGrid.isSelectStatus = false;
textGrid.isRedSelectStatus = false;
}
}
this._confuseHeroPreviewGridIndices.length = 0;
}
/** 地图格 skill 落点预览(优先 TextGrid) */
private _applyMapGridSelectPreview(gridIndex: number, canApply: boolean): void {
const textGrid = this._resolveMapTextGrid(gridIndex);
if (textGrid?.node?.isValid) {
if (canApply) textGrid.isSelectStatus = true;
else textGrid.isRedSelectStatus = true;
return;
}
const mg = this._map?.getMapGridByIndex(gridIndex);
if (!mg?.node?.isValid) return;
if (canApply) mg.isSelectStatus = true;
else mg.isRedSelectStatus = true;
}
/** 清除主动 skill 落点预览 */
private _clearBattleBuffSkillPreview(): void {
this._clearConfuseHeroDropPreview();
if (this._battleBuffPreviewIndex != null) {
const idx = this._battleBuffPreviewIndex;
const prev = this._map?.getMapGridByIndex(idx);
if (prev?.node?.isValid) {
prev.isSelectStatus = false;
prev.isRedSelectStatus = false;
}
const textGrid = this._mapGrids.get(idx);
if (textGrid?.node?.isValid) {
textGrid.isSelectStatus = false;
textGrid.isRedSelectStatus = false;
}
}
if (this._battleBuffPreviewBagSlot != null) {
const slotIndex = this._battleBuffPreviewBagSlot;
this._setBagSlotSelect(slotIndex, false);
const tg = this._slotGrids.get(slotIndex);
if (tg?.node?.isValid) tg.isRedSelectStatus = false;
}
this._battleBuffPreviewIndex = null;
this._battleBuffPreviewBagSlot = null;
}
/** @deprecated 使用 setBattleBuffLevelUpDropPreview */
setBattleBuffDropPreview(gridIndex: number | null, levelDelta: number): void {
if (gridIndex == null) {
this._clearBattleBuffSkillPreview();
return;
}
this.setBattleBuffLevelUpDropPreview({ kind: 'map', gridIndex }, levelDelta);
}
/** 清除 skill 落点预览 */
clearBattleBuffDropPreview(): void {
this._clearBattleBuffSkillPreview();
}
/** 拉线拖动中字块可见区域(幽灵或本体) */
resolveLineDragVisualRect(grid: TextGrid, originGhostMode: boolean): Rect | null {
const visualNode = originGhostMode
? grid.node
: (this._dragGhost?.node ?? grid.node);
if (!visualNode?.isValid) return null;
const ut = visualNode.getComponent(UITransform);
if (!ut) return null;
return ut.getBoundingBoxToWorld();
}
/** 是否可回收地图目标格 */
canApplyBattleBuffRecycleMap(gridIndex: number): boolean {
if (!this._bagSession) return false;
return this._bagSession.merge.canApplyBattleBuffRecycleMap(gridIndex) == null;
}
/** 是否可回收背包槽 */
canApplyBattleBuffRecycleBag(slotIndex: number): boolean {
if (!this._bagSession) return false;
return this._bagSession.merge.canApplyBattleBuffRecycleBag(slotIndex) == null;
}
/** 回收地图字块/单格武将(不含发桃) */
applyBattleBuffRecycleMap(gridIndex: number): boolean {
if (!this._bagSession) return false;
const reason = this._bagSession.merge.applyBattleBuffRecycleMap(gridIndex);
if (reason != null) return false;
return true;
}
/** 回收背包字块(不含发桃) */
applyBattleBuffRecycleBag(slotIndex: number): boolean {
if (!this._bagSession) return false;
const reason = this._bagSession.merge.applyBattleBuffRecycleBag(slotIndex);
if (reason != null) return false;
return true;
}
/** 主动 skill 落点是否可乾坤袋回收 */
canApplyBattleBuffRecycleTarget(target: BattleBuffSkillDropTarget): boolean {
if (target.kind === 'map') return this.canApplyBattleBuffRecycleMap(target.gridIndex);
return this.canApplyBattleBuffRecycleBag(target.slotIndex);
}
/** 提交乾坤袋回收(不含发桃与演出) */
applyBattleBuffRecycleTarget(target: BattleBuffSkillDropTarget): boolean {
if (target.kind === 'map') return this.applyBattleBuffRecycleMap(target.gridIndex);
return this.applyBattleBuffRecycleBag(target.slotIndex);
}
/** 取 skill 落点对应字块视图 */
getTextGridForDropTarget(target: BattleBuffSkillDropTarget): TextGrid | null {
if (target.kind === 'bag') {
const grid = this._slotGrids.get(target.slotIndex);
return grid?.isValid ? grid : null;
}
const grid = this._mapGrids.get(target.gridIndex);
return grid?.isValid ? grid : null;
}
/**
* 乾坤袋演出:字块离槽挂到飞行层。
* @returns 字块当前世界坐标;失败返回 null
*/
liftTextGridForRecyclePresentation(grid: TextGrid, flyLayer: Node): Vec3 | null {
if (!grid?.node?.isValid || !flyLayer?.isValid) return null;
if (!flyLayer.active) flyLayer.active = true;
grid.enableDrag(false);
grid.setMapClickHandler(null);
grid.isSelectStatus = false;
grid.isRedSelectStatus = false;
const wp = grid.node.worldPosition.clone();
this._grids.purgeGridFromIndices(grid);
grid.node.parent = flyLayer;
grid.node.setWorldPosition(wp);
grid.node.setSiblingIndex(flyLayer.children.length - 1);
return wp;
}
/** 乾坤袋演出结束:销毁已离槽字块节点 */
releaseRecyclePresentationGrid(grid: TextGrid | null): void {
if (!grid?.node?.isValid) return;
this._forceRecycleTextGrid(grid);
}
/** 预览乾坤袋回收落点(绿/红 select) */
setBattleBuffRecycleDropPreview(target: BattleBuffSkillDropTarget | null): void {
this._clearBattleBuffSkillPreview();
if (target == null) return;
if (target.kind === 'bag') {
this._battleBuffPreviewBagSlot = target.slotIndex;
const slot = this._bagSession?.getBagSlots()[target.slotIndex];
if (!slot) return;
if (this.canApplyBattleBuffRecycleTarget(target)) {
this._setBagSlotSelect(target.slotIndex, true);
return;
}
const tg = this._slotGrids.get(target.slotIndex);
if (tg?.node?.isValid) tg.isRedSelectStatus = true;
return;
}
this._battleBuffPreviewIndex = target.gridIndex;
const mg = this._map?.getMapGridByIndex(target.gridIndex);
if (!mg?.node?.isValid) return;
if (this.canApplyBattleBuffRecycleTarget(target)) {
mg.isSelectStatus = true;
return;
}
if (this._bagSession?.merge.map.hasTile(target.gridIndex)) {
mg.isRedSelectStatus = true;
}
}
/** 发放局内蟠桃 */
grantBattlePeach(peachCount: number): void {
if (peachCount <= 0) return;
this._battle?.addCoinNum(peachCount);
GEvent.Ins.emit(GEvent.UpdateGameCoinNum);
}
/** 背包槽世界坐标(拉线终点) */
getBagSlotWorldPosition(slotIndex: number): Vec3 | null {
const slot = this._bagSlotNodeAt(slotIndex);
if (!slot?.isValid) return null;
return slot.worldPosition.clone();
}
/** 是否可布置缚仙网/三昧真火(敌方行军路线、无字块、无已有陷阱) */
canPlaceBindNetTrapAt(gridIndex: number, hasTrapAt: (idx: number) => boolean): boolean {
if (!this._map || !this._bagSession) return false;
const mg = this._map.getMapGridByIndex(gridIndex);
if (!mg?.node?.isValid || !mg.isCanWalk) return false;
if (!this._isEnemyMonsterWalkGrid(mg)) return false;
if (this._bagSession.merge.map.hasTile(gridIndex)) return false;
if (hasTrapAt(gridIndex)) return false;
return true;
}
/** 重建敌方行军路线缓存(塔/障碍改变可走性后须失效重算) */
invalidateEnemyMarchPathCache(): void {
this._enemyMarchPathGrids = null;
}
private _rebuildEnemyMarchPathCache(): void {
this._enemyMarchPathGrids = null;
const map = this._map;
const session = this._battle?.session;
if (!map?.gridsInitialized || !session) return;
this._enemyMarchPathGrids = buildEnemyMarchPathGridIndices(map, session);
}
private _isEnemyMonsterWalkGrid(mg: MapGrid | null | undefined): boolean {
const map = this._map;
const session = this._battle?.session;
if (!map || !session || !mg) return false;
if (!this._enemyMarchPathGrids) {
this._rebuildEnemyMarchPathCache();
}
return isEnemyMonsterWalkGrid(map, session, mg, this._enemyMarchPathGrids);
}
/** 屏幕坐标 → 可布置缚仙网/三昧真火的敌方行军格(无字块) */
resolveWalkableTrapGridAtUiPos(uiPos: { x: number; y: number }, hasTrapAt: (idx: number) => boolean): number | null {
if (!this._map || !this._bagSession) return null;
const grids = this._map.mapGrids;
if (!Array.isArray(grids)) return null;
const p = new Vec2(uiPos.x, uiPos.y);
let bestIndex: number | null = null;
let bestDist = Infinity;
for (const mg of grids) {
if (!mg?.node?.isValid || !mg.isCanWalk) continue;
if (!this._isEnemyMonsterWalkGrid(mg)) continue;
if (this._bagSession.merge.map.hasTile(mg.Index)) continue;
if (hasTrapAt(mg.Index)) continue;
const ut = mg.node.getComponent(UITransform);
if (!ut) continue;
const rect = ut.getBoundingBoxToWorld();
if (!rect.contains(p)) continue;
const dist = Vec2.distance(p, rect.center);
if (dist < bestDist) {
bestDist = dist;
bestIndex = mg.Index;
}
}
return bestIndex;
}
/** 预览缚仙网落点 */
setBindNetTrapDropPreview(gridIndex: number | null, hasTrapAt: (idx: number) => boolean): void {
if (this._battleBuffPreviewIndex != null) {
const prev = this._map?.getMapGridByIndex(this._battleBuffPreviewIndex);
if (prev?.node?.isValid) {
prev.isSelectStatus = false;
prev.isRedSelectStatus = false;
}
}
this._battleBuffPreviewIndex = gridIndex;
if (gridIndex == null) return;
const mg = this._map?.getMapGridByIndex(gridIndex);
if (!mg?.node?.isValid) return;
if (this.canPlaceBindNetTrapAt(gridIndex, hasTrapAt)) {
mg.isSelectStatus = true;
return;
}
if (mg.isCanWalk) {
mg.isRedSelectStatus = true;
}
}
/** 地图格世界坐标(拉线终点) */
getMapGridWorldPosition(gridIndex: number): Vec3 | null {
const mg = this._map?.getMapGridByIndex(gridIndex);
if (!mg?.node?.isValid) return null;
return mg.node.worldPosition.clone();
}
/** 刷新地图/背包字块拖拽开关(魅惑等机制会在此之后再次锁定指定格) */
refreshMapDragEnabled(): void {
this._refreshDragEnabled();
}
/**
* 获得词条后:地图上受益且已组合成攻击单位的格子播放「升级效果」。
* @param weapon1Ids 本次词条对应的 Weapon1 id
*/
playSkillPickUpgradeFx(weapon1Ids: ReadonlyMembershipSet<number>): void {
if (!this._bagSession || !this._map || weapon1Ids.size === 0) return;
const played = createMembershipSet<number>();
for (const compose of this._bagSession.compositions) {
if (!weapon1Ids.has(compose.weapon1Id)) continue;
for (const idx of compose.gridIndices) {
if (played.has(idx)) continue;
membershipAdd(played, idx);
this._map.getMapGridByIndex(idx)?.playUpgradeEffect();
}
}
}
/**
* 只让新补上的空格字从砚台飞入,已有字的格子保持不动。
* @param slotIndices 本次写入的槽
* @param fromWorld 起点世界坐标
*/
playNewSlotEntrance(slotIndices: readonly number[], fromWorld?: Readonly<Vec3> | null): void {
this._fillMissingBagSlotGrids();
const flying = new Map<number, TextGrid>();
for (let i = 0; i < slotIndices.length; i++) {
const grid = this._slotGrids.get(slotIndices[i]);
if (!grid?.isValid) continue;
grid.enableDrag(false);
flying.set(slotIndices[i], grid);
}
if (flying.size === 0) return;
this._battle?.forceEndBagDragSlowMo();
const layout = this._bagList?.getComponent(Layout) ?? null;
if (layout) layout.enabled = false;
this._bagEntrancePlaying = true;
this._bagEntranceStartHandler?.();
BagSlotEntranceAnimator.play(
flying,
this._bagList,
this._onBagEntranceFinished,
fromWorld,
this._onBagEntranceSlotLanded,
);
}
private _playBagEntrance(fromWorld?: Readonly<Vec3> | null): void {
this._battle?.forceEndBagDragSlowMo();
this._fillMissingBagSlotGrids();
for (const g of this._slotGrids.values()) {
g.enableDrag(false);
}
const layout = this._bagList?.getComponent(Layout) ?? null;
if (layout) layout.enabled = false;
this._bagEntrancePlaying = true;
this._bagEntranceStartHandler?.();
if (this._slotGrids.size === 0) {
this._onBagEntranceFinished();
return;
}
BagSlotEntranceAnimator.play(
this._slotGrids,
this._bagList,
this._onBagEntranceFinished,
fromWorld,
this._onBagEntranceSlotLanded,
);
}
/** 单格入场落地:立刻可拖,并刷新可合成提示 */
private readonly _onBagEntranceSlotLanded = (grid: TextGrid): void => {
if (!grid?.isValid) return;
grid.enableDrag(this._canEnableBagGridDrag(grid));
this._ensureMapClickHandler(grid);
this._refreshCanUpgradeHints();
};
/** 全部入场结束:开 Layout、对齐槽位、冲刷延迟重建 */
private readonly _onBagEntranceFinished = (): void => {
if (!this._bagEntrancePlaying) return;
this._bagEntrancePlaying = false;
const layout = this._bagList?.getComponent(Layout);
if (layout?.isValid) layout.enabled = true;
this._fillMissingBagSlotGrids();
this._snapBagSlotsAfterEntrance();
this._refreshDragEnabled();
this._refreshCanUpgradeHints();
this._flushAllPendingRebuilds();
this._bagEntranceEndHandler?.();
};
/** 入场结束:字块归位到槽内;session 有字但视图无节点时按数据重建 */
private _snapBagSlotsAfterEntrance(): void {
if (!this._bagSession || !this._bagList) return;
const slots = this._bagSession.getBagSlots();
for (let i = 0; i < slots.length; i++) {
const record = slots[i];
if (!record) continue;
const slotNode = this._bagSlotNodeAt(i);
let grid = this._slotGrids.get(i);
if (!grid?.node?.isValid || (slotNode?.isValid && grid.node.parent !== slotNode)) {
grid = this.ensureBagSlotGrid(i) ?? undefined;
}
if (!grid?.node?.isValid) {
console.log('[CocosBagBoardView] 入场结束仍缺槽', 'slot=', i, 'weapon2Id=', record.weapon2Id);
continue;
}
grid.node.active = true;
grid.node.setPosition(0, 0, 0);
grid.node.angle = 0;
grid.node.setScale(1, 1, 1);
grid.syncPresentationVisibility();
}
}
private _spawnTextGrid(
entity: TextGridEntityData,
parent: Node,
enableDragNow = true,
): TextGrid | null {
const mapIndex = entity.location.kind === 'map' ? entity.location.gridIndex : -1;
const layer = mapIndex >= 0 ? this._mapTextLayer() : null;
const spawnParent = layer ?? parent;
if (spawnParent !== this._dragLayer && spawnParent !== layer) {
const occupant = this._grids.findMapGridOnCell(spawnParent);
if (occupant?.isValid) {
console.warn('[CocosBagBoardView] spawn 拒绝:父节点已有 TextGrid', spawnParent.name, occupant.weapon2Id);
return occupant;
}
}
let node = gg.res.getNode('prefab/', 'textGrid', GBundle.BattleGame);
if (!node?.isValid) {
this._warmTextGridPool();
node = gg.res.getNode('prefab/', 'textGrid', GBundle.BattleGame);
}
if (!node?.isValid) {
node = gg.res.getNode('prefab/', 'textGrid', GBundle.BattleGame);
}
if (!node?.isValid) {
console.log('[TextGrid] textGrid prefab missing / getNode failed', GBundle.BattleGame);
return null;
}
node.active = true;
node.name = 'textGrid';
node.parent = spawnParent;
node.setPosition(0, 0, 0);
const comp = node.getComponent(TextGrid) ?? node.addComponent(TextGrid);
this._writeGridData(comp, entity);
if (mapIndex >= 0) this._placeMapTextGrid(comp, mapIndex);
comp.rememberOrigin();
const dragOn = entity.location.kind === 'bag'
? this._canEnableBagGridDrag(comp)
: this._canDrag();
comp.enableDrag(enableDragNow && dragOn);
comp.setMapClickHandler((g) => this._onTextGridClick(g));
this._bindDrag(comp);
return comp;
}
/** 复用/重建后确保字块点击回调存在(prepareForPool 会清 handler) */
private _ensureMapClickHandler(grid: TextGrid | null | undefined): void {
if (!grid?.isValid) return;
grid.setMapClickHandler((g) => this._onTextGridClick(g));
}
/** 空父节点上的字块一律回池(session 已无 tile/record)。有 keep 时只清未登记的残留,不猜该留哪块 */
private _purgeTextGridsOnNode(parent: Node, keep?: TextGrid): void {
if (!parent?.isValid) return;
for (const child of [...parent.children]) {
const tg = child.getComponent(TextGrid);
if (!tg?.isValid) continue;
if (keep && tg === keep) continue;
if (keep && this._grids.isIndexed(tg)) {
console.warn('[CocosBagBoardView] 同父两块均已登记,不回收', parent.name, keep.weapon2Id, tg.weapon2Id);
continue;
}
this._forceRecycleTextGrid(tg);
}
}
/** 同父出现第二块 TextGrid 时暴露,不擅自回收 */
private _warnExtraTextGrids(parent: Node, keep: TextGrid): void {
const extra = this._grids.findOtherGridOnCell(parent, keep);
if (!extra) return;
console.warn('[CocosBagBoardView] 同父出现第二块 TextGrid', parent.name, keep.weapon2Id, extra.weapon2Id);
}
/** 池化 textGrid 必须 putNode;回池前须从槽/地图索引摘掉,并结算入场 finished */
private _recycleTextGridNode(node: Node | null | undefined): void {
if (!node?.isValid) return;
const grid = node.getComponent(TextGrid);
if (grid?.isValid) {
this._forgetGridBind(grid);
if (this._isDragGhostNode(node) && !this._grids.isIndexed(grid)) {
this._forceRecycleTextGrid(grid);
return;
}
if (this._isMapHostedTextGrid(grid) || this._isTextGridTracked(grid)) {
return;
}
this._grids.purgeGridFromIndices(grid);
// invokeDone=true:若正参与入场,必须推进 finished,否则总 onComplete 永不触发
BagSlotEntranceAnimator.stopGrid(grid, true);
grid.prepareForPool();
} else {
Tween.stopAllByTarget(node);
}
if (node['resKey']) gg.res.putNode(node);
else node.destroy();
}
/** rebuild 后扫描专用层:无 tile 的地图字只 warn */
private _purgeOrphanTextGridsOnMap(): void {
const layer = this._mapTextLayer();
if (!layer?.isValid) return;
const children = layer.children;
for (let i = 0; i < children.length; i++) {
const tg = children[i].getComponent(TextGrid);
if (!tg?.isValid) continue;
const loc = tg.location;
if (loc?.kind !== 'map') continue;
if (this._isEnemyPlaceCampCell(loc.gridIndex)) continue;
if (this._mapGrids.get(loc.gridIndex) === tg) continue;
console.warn('[CocosBagBoardView] 专用层出现未索引地图字', loc.gridIndex, tg.weapon2Id);
}
}
private _bindDrag(grid: TextGrid): void {
const dragNode = grid.dragNode;
dragNode.off(Node.DragEvent.DRAG_START, undefined, this);
dragNode.off(Node.DragEvent.DRAG_MOVE, undefined, this);
dragNode.off(Node.DragEvent.DRAG_END, undefined, this);
dragNode.on(Node.DragEvent.DRAG_START, () => this._onDragStart(grid), this);
dragNode.on(Node.DragEvent.DRAG_MOVE, (e: EventTouch) => this._onDragMove(grid, e), this);
dragNode.on(Node.DragEvent.DRAG_END, (e: EventTouch) => this._onDragEnd(grid, e), this);
}
private _onDragStart(grid: TextGrid): void {
if (!this._canDrag() || BagSlotEntranceAnimator.isPlaying(grid)) {
grid.dragNode.stopDrag();
return;
}
if (!grid.location) {
grid.dragNode.stopDrag();
return;
}
const resolvedMapIndex = grid.location.kind === 'map'
? this._resolveMapGridIndexForTextGrid(grid)
: null;
if (resolvedMapIndex != null
&& this._battle?.charmController.isGridCharmed(resolvedMapIndex)) {
grid.dragNode.stopDrag();
return;
}
let source = this._resolveDragSourceForTextGrid(grid);
if (!source) {
grid.dragNode.stopDrag();
return;
}
if (source.kind === 'bag' && this._land.isNoDrag(source.slotIndex)) {
grid.dragNode.stopDrag();
return;
}
if (source.kind === 'bag') {
grid.setLocation({ kind: 'bag', slotIndex: source.slotIndex });
} else {
grid.setLocation({ kind: 'map', gridIndex: source.gridIndex });
}
grid.resetNodeTransform();
let mapDrag: { fromGridIndex: number; weapon2Id: number; tileSource?: TileSource } | undefined;
let type3Backup: { gridIndex: number; tile: MapTileRecord } | null = null;
// 任意拖动先清解锁引导,避免框/连线参与合成节点树造成叠字
this._unlockGuide.clear();
if (source.kind === 'bag') {
const slot = this._bagSession!.getBagSlots()[source.slotIndex];
if (!slot || Number(slot.weapon2Id) !== grid.weapon2Id) {
grid.dragNode.stopDrag();
this._rebuildFromSession();
return;
}
} else if (source.kind === 'map') {
const mapIndex = this._resolveMapGridIndexForTextGrid(grid);
if (mapIndex == null) {
grid.dragNode.stopDrag();
this._rebuildFromSession();
return;
}
source = { kind: 'map', gridIndex: mapIndex };
const liveTile = this._bagSession!.merge.map.getTile(mapIndex);
if (!liveTile || Number(liveTile.weapon2Id) !== grid.weapon2Id) {
grid.dragNode.stopDrag();
this._rebuildFromSession();
return;
}
mapDrag = {
fromGridIndex: mapIndex,
weapon2Id: grid.weapon2Id,
tileSource: liveTile.source,
};
const result = this._bagSession!.pickupFromMap(mapIndex);
if ('error' in result) {
grid.restoreOrigin();
grid.dragNode.stopDrag();
return;
}
if (result.wasType3) {
type3Backup = { gridIndex: source.gridIndex, tile: result.tile };
this._mapGrids.delete(source.gridIndex);
mapDrag.tileSource = result.tile.source;
}
}
grid.rememberOrigin();
BattleAudio.playWeaponDrag();
const lineDragMode = source.kind === 'bag' || source.kind === 'map';
if (lineDragMode) {
grid.setDragFollow(false);
}
this._clearTextSelect();
const debugSeq = BagTextDragDebugLog.begin(grid, 'dragStart');
this._textDragDebugSeq = debugSeq;
this._activeDrag = { grid, source, type3Backup, lineDragMode, mapDrag, debugSeq };
this._showDragGuide(grid.weapon2Id, {
level: grid.level,
weapon1Id: grid.weapon1Id > 0 ? grid.weapon1Id : undefined,
source: mapDrag?.tileSource,
fromMapGrid: mapDrag?.fromGridIndex,
});
this._showMergeTargetShake(this._activeDrag);
if (lineDragMode) {
if (source.kind === 'bag') {
BagTextDragDebugLog.slotChildren(source.slotIndex, this._bagSlotNodeAt(source.slotIndex), 'beforeGhost');
}
this._ensureDragGhost(grid);
BagTextDragDebugLog.snap(debugSeq, 'ghostReady', this._dragGhost, 'ghost');
this._assertBagSourceStillInSlot(grid, source);
grid.ensureBagGlyphVisible();
BagTextDragDebugLog.snap(debugSeq, 'sourceAfterGhost', grid, 'source');
}
if (this._map && this._battle) {
this._pathFlow?.beginDrag(this._map, this._battle.session, this._isType3Drag(grid));
}
this._battle?.beginBagDragSlowMo();
if (lineDragMode) {
this._updateBagLineDrag(grid);
}
}
private _onDragMove(grid: TextGrid, event: EventTouch): void {
const drag = this._activeDrag;
if (!drag || drag.grid !== grid) return;
if (drag.lineDragMode) {
grid.setDragFollow(false);
grid.dragNode.setPosition(0, 0, 0);
grid.restoreOrigin();
this._updateBagLineDrag(grid, event);
}
}
private _updateBagLineDrag(grid: TextGrid, event?: EventTouch): void {
if (!this._bagSession) {
this._linePresenter.hide();
this._setDragSelectTarget(null);
return;
}
const uiPos = event?.getUILocation();
if (!uiPos) {
this._linePresenter.hide();
this._setDragSelectTarget(null);
return;
}
this._syncDragGhostToTouch(uiPos);
const drag = this._activeDrag;
const lineFrom = this._lineDragAnchorWorld(grid, drag);
const lineTo = this._lineDragCursorWorld(grid, drag);
const fromSlot = drag?.source.kind === 'bag' ? drag.source.slotIndex : -1;
const fromMapIndex = this._mapDragFromIndex(drag);
const bagSlotIndex = this._resolveBagSlotAt(uiPos);
if (bagSlotIndex != null) {
const bagSwap = fromSlot >= 0 && bagSlotIndex !== fromSlot;
const mapToBag = fromMapIndex >= 0;
if (bagSwap || mapToBag) {
const bagSlotNode = this._bagSlotNodeAt(bagSlotIndex);
if (bagSlotNode?.isValid) {
this._setDragSelectTarget({ kind: 'bag', index: bagSlotIndex });
if (lineFrom) {
this._linePresenter.show(lineFrom, bagSlotNode.worldPosition);
} else {
this._linePresenter.hide();
}
if (drag && this._isType3Drag(grid, drag)) {
this._updateType3PathPreview(null, drag);
}
return;
}
}
}
if (!this._map) {
if (lineFrom && lineTo) {
this._linePresenter.show(lineFrom, lineTo);
} else {
this._linePresenter.hide();
}
this._setDragSelectTarget(null);
if (drag && this._isType3Drag(grid, drag)) {
this._updateType3PathPreview(null, drag);
}
return;
}
const mapIndex = this._resolveMapGridAt(uiPos);
if (mapIndex == null) {
if (lineFrom && lineTo) {
this._linePresenter.show(lineFrom, lineTo);
} else {
this._linePresenter.hide();
}
this._setDragSelectTarget(null);
if (drag && this._isType3Drag(grid, drag)) {
this._updateType3PathPreview(null, drag);
}
return;
}
if (fromMapIndex >= 0 && mapIndex === fromMapIndex) {
this._linePresenter.hide();
this._setDragSelectTarget(null);
if (this._isType3Drag(grid, drag)) {
this._updateType3PathPreview(null, drag);
}
return;
}
const mapGrid = this._map.getMapGridByIndex(mapIndex);
if (!mapGrid?.node?.isValid) {
this._linePresenter.hide();
this._setDragSelectTarget(null);
if (drag && this._isType3Drag(grid, drag)) {
this._updateType3PathPreview(null, drag);
}
return;
}
WeaponComposeConfigProvider.instance.load();
const w2Type = Number(
WeaponComposeConfigProvider.instance.getWeapon2Type(grid.weapon2Id),
) as Weapon2TileType;
const dragSource = this._buildDragTileSourceInfo(grid, drag);
const hint = MapDragGuideHelper.getMapHoverHint(
this._bagSession,
w2Type,
mapGrid,
dragSource,
);
this._setDragSelectTarget({ kind: 'map', index: mapIndex, hint }, grid);
if (lineFrom) {
this._linePresenter.show(lineFrom, mapGrid.node.worldPosition);
} else {
this._linePresenter.hide();
}
if (this._isType3Drag(grid, drag)) {
this._updateType3PathPreview(mapIndex, drag);
}
}
private _onDragEnd(grid: TextGrid, event: EventTouch): void {
const drag = this._activeDrag;
this._activeDrag = null;
let bagAttackSpeedPlace: { source: DragSource; target: DropTarget } | null = null;
if (drag?.lineDragMode) {
try {
this._onBagLineDragEnd(grid, event, drag);
} finally {
this._flushAllPendingRebuilds();
}
return;
}
try {
if (!drag || !this._bagSession) {
grid.restoreOrigin();
this._disposeEphemeralDragGrid(grid, drag?.source);
return;
}
const target = this._resolveDropTarget(event, drag.source);
if (target.kind === 'bag' && this._land.isBlank(target.slotIndex)) {
this._discardGlyphToBlank(grid, drag);
return;
}
if (target.kind === 'bag' && this._interceptLandDrop(target.slotIndex)) {
if (drag.type3Backup) {
this._bagSession.revertType3Pickup(drag.type3Backup.gridIndex, drag.type3Backup.tile);
}
grid.restoreOrigin();
this._disposeEphemeralDragGrid(grid, drag.source);
return;
}
this._beginDropViewSync();
const reason = this._bagSession.drop(drag.source, target);
if (reason) {
this._cancelDropViewSync();
if (drag.type3Backup) {
this._bagSession.revertType3Pickup(drag.type3Backup.gridIndex, drag.type3Backup.tile);
}
grid.restoreOrigin();
const msg = rejectReasonToMessage(reason);
if (msg) gg.ui.showToast(msg);
this._disposeEphemeralDragGrid(grid, drag.source);
this._rebuildFromSession();
return;
}
const cancelled = isDropCancelled(drag.source, target);
if (cancelled) {
this._cancelDropViewSync();
if (drag.type3Backup) {
this._bagSession.revertType3Pickup(drag.type3Backup.gridIndex, drag.type3Backup.tile);
}
grid.restoreOrigin();
} else {
this._commitViewsAfterSuccessfulDrop(grid, target);
this._vibratePlaceSuccess();
if (drag.source.kind === 'bag' && target.kind === 'map') {
bagAttackSpeedPlace = { source: drag.source, target };
}
}
this._disposeEphemeralDragGrid(grid, drag.source);
} finally {
grid.setDragFollow(true);
this._clearDragGuide();
this._linePresenter.hide();
this._flushAllPendingRebuilds();
if (bagAttackSpeedPlace) {
this._afterBagAttackSpeedPlace(bagAttackSpeedPlace.source, bagAttackSpeedPlace.target);
}
}
}
/** 背包拉线放置:仅手指在可放置格/背包槽上松手才落子,并播跳入动画 */
private _onBagLineDragEnd(
grid: TextGrid,
event: EventTouch,
drag: NonNullable<typeof this._activeDrag>,
): void {
try {
grid.setDragFollow(true);
this._linePresenter.hide();
// 松手即隐藏 select,避免互换动画期间仍显示在目标格上
this._setDragSelectTarget(null);
if (!this._bagSession || !this._dragLayer?.isValid) {
grid.restoreOrigin();
const seq = drag.debugSeq ?? this._textDragDebugSeq;
if (seq) BagTextDragDebugLog.end(seq, grid, 'lineAbort:noSession');
this._textDragDebugSeq = 0;
return;
}
const uiPos = event.getUILocation();
const fromSlot = drag.source.kind === 'bag' ? drag.source.slotIndex : -1;
const fromMapIndex = this._mapDragFromIndex(drag);
const seq = drag.debugSeq ?? this._textDragDebugSeq;
if (seq) {
BagTextDragDebugLog.snap(
seq,
'release',
grid,
`finger=(${uiPos.x.toFixed(1)},${uiPos.y.toFixed(1)}) fromSlot=${fromSlot} fromMap=${fromMapIndex}`,
);
BagTextDragDebugLog.snap(seq, 'releaseGhost', this._dragGhost, 'ghost');
}
const bagSlotIndex = this._resolveBagSlotAt(uiPos);
if (bagSlotIndex != null && this._land.isBlank(bagSlotIndex)) {
this._discardGlyphToBlank(grid, drag);
return;
}
if (bagSlotIndex != null && this._interceptLandDrop(bagSlotIndex)) {
this._lineDragCancel(grid, drag);
return;
}
if (fromSlot >= 0 && bagSlotIndex != null && bagSlotIndex !== fromSlot) {
if (seq) {
BagTextDragDebugLog.snap(seq, 'toBag', grid, `fromSlot=${fromSlot}→toSlot=${bagSlotIndex}`);
}
this._onBagLineDragEndToBag(grid, drag, fromSlot, bagSlotIndex, event);
return;
}
if (fromMapIndex >= 0 && bagSlotIndex != null) {
if (seq) {
BagTextDragDebugLog.snap(seq, 'mapToBag', grid, `fromMap=${fromMapIndex}→toSlot=${bagSlotIndex}`);
}
this._onLineDragEndMapToBag(grid, drag, fromMapIndex, bagSlotIndex, event);
return;
}
if (!this._map) {
this._lineDragCancel(grid, drag);
return;
}
const mapIndex = this._resolveMapGridAt(uiPos);
const dragSource = this._buildDragTileSourceInfo(grid, drag);
if (
mapIndex == null
|| !MapDragGuideHelper.isBagDropLineTarget(
this._map,
this._bagSession,
dragSource,
mapIndex,
)
) {
if (mapIndex != null && this._map && this._bagSession) {
const mg = this._map.getMapGridByIndex(mapIndex);
if (mg?.node?.isValid) {
WeaponComposeConfigProvider.instance.load();
const w2Type = Number(
WeaponComposeConfigProvider.instance.getWeapon2Type(grid.weapon2Id),
) as Weapon2TileType;
const msg = MapDragGuideHelper.getMapDropFailMessage(
this._bagSession,
w2Type,
mg,
dragSource,
);
if (msg) gg.ui.showToast(msg);
}
}
this._lineDragCancel(grid, drag);
return;
}
if (fromMapIndex >= 0 && mapIndex === fromMapIndex) {
this._lineDragCancel(grid, drag);
return;
}
if (fromMapIndex >= 0) {
const s = drag.debugSeq ?? this._textDragDebugSeq;
if (s) {
BagTextDragDebugLog.snap(s, 'mapToMap', grid, `fromMap=${fromMapIndex}→toMap=${mapIndex}`);
}
this._onLineDragEndMapToMap(grid, drag, fromMapIndex, mapIndex, event);
return;
}
const mapGrid = this._map.getMapGridByIndex(mapIndex);
if (!mapGrid?.node?.isValid) {
this._lineDragCancel(grid, drag);
return;
}
const slotIndex = drag.source.kind === 'bag' ? drag.source.slotIndex : -1;
const slot = slotIndex >= 0 ? this._bagSession.getBagSlots()[slotIndex] : null;
if (slot) {
const reject = this._rejectMapDropUpgrade(slot, mapIndex);
if (reject) {
grid.restoreOrigin();
const seqReject = drag.debugSeq ?? this._textDragDebugSeq;
if (seqReject) BagTextDragDebugLog.end(seqReject, grid, 'rejectUpgrade');
this._textDragDebugSeq = 0;
const msg = rejectReasonToMessage(reject);
if (msg) gg.ui.showToast(msg);
return;
}
}
const target: DropTarget = { kind: 'map', gridIndex: mapIndex };
const seqMap = drag.debugSeq ?? this._textDragDebugSeq;
if (seqMap) {
BagTextDragDebugLog.snap(seqMap, 'bagToMap', grid, `fromSlot=${slotIndex}→toMap=${mapIndex}`);
}
const targetWorld = mapGrid.node.worldPosition.clone();
const bagSlotNode = slotIndex >= 0 ? this._bagSlotNodeAt(slotIndex) : null;
const displacedGrid = this._mapGrids.get(mapIndex) ?? null;
const swapToBag = displacedGrid?.node?.isValid
&& bagSlotNode?.isValid
&& this._willBagMapSwap(slotIndex, mapIndex);
if (swapToBag) {
this._detachGridForFlight(displacedGrid!, { kind: 'map', gridIndex: mapIndex });
}
this._detachGridForFlight(grid, { kind: 'bag', slotIndex });
const flightGen = this._placeFlightGen;
const placeStartWorld = this._resolveLineDragPlaceStartWorld(grid, drag, event);
const displacedStartWorld = swapToBag && displacedGrid?.node?.isValid
? displacedGrid.node.worldPosition.clone()
: null;
BoardPlaceFlightExecutor.execute({
dragLayer: this._dragLayer,
flightGen,
dropSource: drag.source,
dropTarget: target,
primaryLeg: {
grid,
targetWorld,
attachParent: swapToBag || !displacedGrid?.node?.isValid ? this._mapPlaceAttach(mapIndex).attachParent : undefined,
restWorld: swapToBag || !displacedGrid?.node?.isValid ? this._mapPlaceAttach(mapIndex).restWorld : undefined,
vacateFrom: { kind: 'bag', slotIndex },
startWorld: placeStartWorld,
},
swapLeg: swapToBag && bagSlotNode && displacedStartWorld
? {
grid: displacedGrid!,
targetWorld: bagSlotNode.worldPosition,
attachParent: bagSlotNode,
vacateFrom: { kind: 'map', gridIndex: mapIndex },
startWorld: displacedStartWorld,
}
: null,
swapCommit: swapToBag && displacedGrid
? { grid: displacedGrid, target: { kind: 'bag', slotIndex } }
: undefined,
hooks: this._createPlaceHooks({
onSuccessRebuild: 'flush',
beforeAnim: () => this._setMapHeroFlightVisible(mapIndex, false),
onComplete: () => {
this._setMapHeroFlightVisible(mapIndex, true);
this._repairMapTileViewAfterDrop(mapIndex);
this._refreshDragEnabled();
},
}),
});
} finally {
this._clearDragGuide();
}
}
/** 背包拉线:松手到另一背包槽(移动/合成/互换) */
private _onBagLineDragEndToBag(
grid: TextGrid,
drag: NonNullable<typeof this._activeDrag>,
fromSlot: number,
toSlot: number,
event: EventTouch,
): void {
const targetBagNode = this._bagSlotNodeAt(toSlot);
const sourceBagNode = this._bagSlotNodeAt(fromSlot);
if (!targetBagNode?.isValid || !sourceBagNode?.isValid || !this._dragLayer?.isValid) {
grid.restoreOrigin();
return;
}
const displacedGrid = this._slotGrids.get(toSlot) ?? null;
const swapToSource = displacedGrid?.node?.isValid
&& this._willBagBagSwap(fromSlot, toSlot);
if (swapToSource) {
this._detachGridForFlight(displacedGrid!, { kind: 'bag', slotIndex: toSlot });
}
this._detachGridForFlight(grid, { kind: 'bag', slotIndex: fromSlot });
const flightGen = this._placeFlightGen;
const placeStartWorld = this._resolveLineDragPlaceStartWorld(grid, drag, event);
const displacedStartWorld = swapToSource && displacedGrid?.node?.isValid
? displacedGrid.node.worldPosition.clone()
: null;
BoardPlaceFlightExecutor.execute({
dragLayer: this._dragLayer,
flightGen,
dropSource: drag.source,
dropTarget: { kind: 'bag', slotIndex: toSlot },
primaryLeg: {
grid,
targetWorld: targetBagNode.worldPosition,
attachParent: swapToSource || !displacedGrid?.node?.isValid ? targetBagNode : undefined,
vacateFrom: { kind: 'bag', slotIndex: fromSlot },
startWorld: placeStartWorld,
},
swapLeg: swapToSource && displacedStartWorld
? {
grid: displacedGrid!,
targetWorld: sourceBagNode.worldPosition,
attachParent: sourceBagNode,
vacateFrom: { kind: 'bag', slotIndex: toSlot },
startWorld: displacedStartWorld,
}
: null,
swapCommit: swapToSource && displacedGrid
? { grid: displacedGrid, target: { kind: 'bag', slotIndex: fromSlot } }
: undefined,
hooks: this._createPlaceHooks({
onSuccessRebuild: 'flush',
onComplete: () => this._refreshDragEnabled(),
}),
});
}
/** 拉线拖拽取消:字块回原位,type3 还原格状态 */
private _lineDragCancel(grid: TextGrid, drag: NonNullable<typeof this._activeDrag>): void {
if (drag.type3Backup && this._bagSession) {
this._bagSession.revertType3Pickup(drag.type3Backup.gridIndex, drag.type3Backup.tile);
}
grid.restoreOrigin();
const seq = drag.debugSeq ?? this._textDragDebugSeq;
if (seq) BagTextDragDebugLog.end(seq, grid, 'lineCancel');
grid.ensureBagGlyphVisible();
this._fillMissingBagSlotGrids();
this._textDragDebugSeq = 0;
}
/** 地图拉线:松手到背包槽(移动/合成/互换) */
private _onLineDragEndMapToBag(
grid: TextGrid,
drag: NonNullable<typeof this._activeDrag>,
fromMapIndex: number,
toSlot: number,
event: EventTouch,
): void {
const targetBagNode = this._bagSlotNodeAt(toSlot);
const sourceMapGrid = this._map?.getMapGridByIndex(fromMapIndex);
if (!targetBagNode?.isValid || !sourceMapGrid?.node?.isValid || !this._dragLayer?.isValid) {
this._lineDragCancel(grid, drag);
return;
}
const displacedGrid = this._slotGrids.get(toSlot) ?? null;
const swapToMap = displacedGrid?.node?.isValid
&& this._willMapBagSwap(fromMapIndex, toSlot);
if (swapToMap) {
this._detachGridForFlight(displacedGrid!, { kind: 'bag', slotIndex: toSlot });
}
this._detachGridForFlight(grid, { kind: 'map', gridIndex: fromMapIndex });
const flightGen = this._placeFlightGen;
const placeStartWorld = this._resolveLineDragPlaceStartWorld(grid, drag, event);
const displacedStartWorld = swapToMap && displacedGrid?.node?.isValid
? displacedGrid.node.worldPosition.clone()
: null;
const fromIdx = drag.mapDrag?.fromGridIndex ?? fromMapIndex;
BoardPlaceFlightExecutor.execute({
dragLayer: this._dragLayer,
flightGen,
dropSource: { kind: 'map', gridIndex: fromIdx },
dropTarget: { kind: 'bag', slotIndex: toSlot },
primaryLeg: {
grid,
targetWorld: targetBagNode.worldPosition,
attachParent: swapToMap || !displacedGrid?.node?.isValid ? targetBagNode : undefined,
vacateFrom: { kind: 'map', gridIndex: fromMapIndex },
startWorld: placeStartWorld,
},
swapLeg: swapToMap && displacedStartWorld
? {
grid: displacedGrid!,
targetWorld: sourceMapGrid.node.worldPosition,
attachParent: this._mapPlaceAttach(fromMapIndex).attachParent,
restWorld: this._mapPlaceAttach(fromMapIndex).restWorld,
vacateFrom: { kind: 'bag', slotIndex: toSlot },
startWorld: displacedStartWorld,
}
: null,
swapCommit: swapToMap && displacedGrid
? { grid: displacedGrid, target: { kind: 'map', gridIndex: fromMapIndex } }
: undefined,
hooks: this._createPlaceHooks({
onSuccessRebuild: 'flush',
beforeAnim: () => this._setMapHeroFlightVisible(fromMapIndex, false),
onComplete: (ok) => {
this._setMapHeroFlightVisible(fromMapIndex, true);
this._repairMapTileViewAfterDrop(fromMapIndex);
if (ok) {
// 地图→背包落地后再强制露数字,避免只剩阴影
const bagGrid = this._slotGrids.get(toSlot);
bagGrid?.ensureBagGlyphVisible();
}
this._refreshDragEnabled();
},
validateBeforeDrop: () => {
const snap = drag.mapDrag;
if (snap && (snap.fromGridIndex !== fromMapIndex || snap.weapon2Id !== grid.weapon2Id)) {
return 'rebuild';
}
const liveFrom = this._bagSession?.merge.map.getTile(fromIdx);
if (!liveFrom || Number(liveFrom.weapon2Id) !== grid.weapon2Id) {
return 'rebuild';
}
return null;
},
}),
});
}
/** 地图拉线:松手到另一地图格(移动/互换) */
private _onLineDragEndMapToMap(
grid: TextGrid,
drag: NonNullable<typeof this._activeDrag>,
fromMapIndex: number,
toMapIndex: number,
event: EventTouch,
): void {
const mapGrid = this._map?.getMapGridByIndex(toMapIndex);
const sourceMapGrid = this._map?.getMapGridByIndex(fromMapIndex);
if (!mapGrid?.node?.isValid || !sourceMapGrid?.node?.isValid || !this._dragLayer?.isValid) {
this._lineDragCancel(grid, drag);
return;
}
const srcTile = this._bagSession?.merge.map.getTile(fromMapIndex);
if (srcTile) {
const reject = this._rejectMapDropUpgrade(srcTile, toMapIndex, fromMapIndex);
if (reject) {
this._lineDragCancel(grid, drag);
const msg = rejectReasonToMessage(reject);
if (msg) gg.ui.showToast(msg);
return;
}
}
const targetWorld = mapGrid.node.worldPosition.clone();
const sourceWorld = sourceMapGrid.node.worldPosition.clone();
const displacedGrid = this._mapGrids.get(toMapIndex) ?? null;
const swapMap = displacedGrid?.node?.isValid
&& this._willMapMapSwap(fromMapIndex, toMapIndex);
if (swapMap) {
this._detachGridForFlight(displacedGrid!, { kind: 'map', gridIndex: toMapIndex });
}
this._detachGridForFlight(grid, { kind: 'map', gridIndex: fromMapIndex });
const flightGen = this._placeFlightGen;
const placeStartWorld = this._resolveLineDragPlaceStartWorld(grid, drag, event);
const displacedStartWorld = swapMap && displacedGrid?.node?.isValid
? displacedGrid.node.worldPosition.clone()
: null;
const snapFrom = drag.mapDrag?.fromGridIndex ?? fromMapIndex;
BoardPlaceFlightExecutor.execute({
dragLayer: this._dragLayer,
flightGen,
dropSource: { kind: 'map', gridIndex: snapFrom },
dropTarget: { kind: 'map', gridIndex: toMapIndex },
primaryLeg: {
grid,
targetWorld,
attachParent: swapMap || !displacedGrid?.node?.isValid ? this._mapPlaceAttach(toMapIndex).attachParent : undefined,
restWorld: swapMap || !displacedGrid?.node?.isValid ? this._mapPlaceAttach(toMapIndex).restWorld : undefined,
vacateFrom: { kind: 'map', gridIndex: fromMapIndex },
startWorld: placeStartWorld,
},
swapLeg: swapMap && displacedStartWorld
? {
grid: displacedGrid!,
targetWorld: sourceWorld,
attachParent: this._mapPlaceAttach(fromMapIndex).attachParent,
restWorld: this._mapPlaceAttach(fromMapIndex).restWorld,
vacateFrom: { kind: 'map', gridIndex: toMapIndex },
startWorld: displacedStartWorld,
}
: null,
swapCommit: swapMap && displacedGrid
? { grid: displacedGrid, target: { kind: 'map', gridIndex: fromMapIndex } }
: undefined,
hooks: this._createPlaceHooks({
onSuccessRebuild: 'flush',
beforeAnim: () => {
this._setMapHeroFlightVisible(fromMapIndex, false);
this._setMapHeroFlightVisible(toMapIndex, false);
},
onComplete: () => {
grid.setDragFollow(true);
this._restoreHeroActorAfterMapFlight(fromMapIndex, toMapIndex);
this._repairMapTileViewAfterDrop(fromMapIndex);
this._repairMapTileViewAfterDrop(toMapIndex);
this._refreshDragEnabled();
},
validateBeforeDrop: () => {
const snap = drag.mapDrag;
if (!snap || snap.weapon2Id !== grid.weapon2Id) return 'rebuild';
const liveFrom = this._bagSession?.merge.map.getTile(snap.fromGridIndex);
if (!liveFrom || Number(liveFrom.weapon2Id) !== snap.weapon2Id) return 'rebuild';
return null;
},
}),
});
}
/** 背包槽互换:仅同级可合成时不交换;异级/满级都交换 */
private _willBagBagSwap(fromSlot: number, toSlot: number): boolean {
if (fromSlot === toSlot || !this._bagSession) return false;
if (this._land.stateOf(toSlot) !== TextListCellState.Open) return false;
const slots = this._bagSession.getBagSlots();
const src = slots[fromSlot];
const dst = slots[toSlot];
if (!src || !dst) return false;
if (this._willElementMerge(src, dst)) return false;
return true;
}
/** 背包落子到已占格时,是否会与地图字块交换到背包 */
private _willBagMapSwap(slotIndex: number, gridIndex: number): boolean {
if (!this._bagSession) return false;
const slot = this._bagSession.getBagSlots()[slotIndex];
const targetTile = this._bagSession.merge.map.getTile(gridIndex);
if (!slot || !targetTile) return false;
// 产卡只消耗自身升级目标,不与地图字交换
if (Math.floor(Number(slot.battleCardKind) || 0) > 0) return false;
if (!this._isMapMergeBlocked(gridIndex) && this._willElementMerge(slot, targetTile)) return false;
WeaponComposeConfigProvider.instance.load();
const provider = WeaponComposeConfigProvider.instance;
if (this._willKeepDigSwap(slot, targetTile)) return true;
const srcType = provider.getWeapon2Type(slot.weapon2Id);
const dstType = provider.getWeapon2Type(targetTile.weapon2Id);
if (!isElementGlyphType(srcType) || !isElementGlyphType(dstType)) return false;
const cellKind = this._bagSession.validator.getLiveCellKind(gridIndex)
?? this._bagSession.merge.map.getCellKind(gridIndex);
if (cellKind !== MapCellKind.Put) return false;
return canDragMapTile(targetTile, dstType as Weapon2TileType, false);
}
/** 地图字块落子到已占背包槽时,是否会与背包字块交换到地图 */
private _willMapBagSwap(fromMapIndex: number, toSlot: number): boolean {
if (this._land.stateOf(toSlot) !== TextListCellState.Open) return false;
const tile = this._bagSession?.merge.map.getTile(fromMapIndex);
const slot = this._bagSession?.getBagSlots()[toSlot];
if (tile && slot && this._willElementMerge(tile, slot)) return false;
return this._willBagMapSwap(toSlot, fromMapIndex);
}
/** 地图字块落子到已占地图格时,是否会与目标字块交换 */
private _willMapMapSwap(fromMapIndex: number, toMapIndex: number): boolean {
if (fromMapIndex === toMapIndex || !this._bagSession) return false;
const srcTile = this._bagSession.merge.map.getTile(fromMapIndex);
const dstTile = this._bagSession.merge.map.getTile(toMapIndex);
if (!srcTile || !dstTile) return false;
if (!this._isMapMergeBlocked(fromMapIndex) && !this._isMapMergeBlocked(toMapIndex)
&& this._willElementMerge(srcTile, dstTile)) return false;
WeaponComposeConfigProvider.instance.load();
const provider = WeaponComposeConfigProvider.instance;
if (this._willKeepDigSwap(srcTile, dstTile)) return true;
const srcType = provider.getWeapon2Type(srcTile.weapon2Id);
const dstType = provider.getWeapon2Type(dstTile.weapon2Id);
if (!isElementGlyphType(srcType) || !isElementGlyphType(dstType)) return false;
const cellKind = this._bagSession.validator.getLiveCellKind(toMapIndex)
?? this._bagSession.merge.map.getCellKind(toMapIndex);
if (cellKind !== MapCellKind.Put) return false;
return canDragMapTile(dstTile, dstType as Weapon2TileType, false);
}
/**
* 蟠桃树等 keep-dig 文字:不能合成时是否互换。
* 与 MergeSession._tryKeepDigSwap / _tryKeepDigBagMapSwap 对齐。
*/
private _willKeepDigSwap(
src: { weapon2Id: number; weapon1Id?: number; level?: number },
dst: { weapon2Id: number; weapon1Id?: number; level?: number },
): boolean {
WeaponComposeConfigProvider.instance.load();
const provider = WeaponComposeConfigProvider.instance;
if (!provider.passiveSpawnKeepsDigCell(src.weapon2Id)) return false;
if (!provider.passiveSpawnKeepsDigCell(dst.weapon2Id)) return false;
if (src.weapon2Id !== dst.weapon2Id) return true;
const srcLevel = src.level ?? 1;
const dstLevel = dst.level ?? 1;
if (srcLevel !== dstLevel) return true;
return srcLevel >= provider.getKeepDigTileMaxLevel(src.weapon2Id);
}
/**
* 来源落到目标格是否触发五行同级合成。
* @param source 源字
* @param dropGridIndex 目标格
* @param fromMapGrid 源地图格
*/
private _isUpgradeMaterialAtMapDrop(
source: { weapon2Id: number; weapon1Id?: number; level?: number },
dropGridIndex: number,
fromMapGrid?: number,
): boolean {
if (this._isMapMergeBlocked(dropGridIndex)) return false;
if (fromMapGrid != null && this._isMapMergeBlocked(fromMapGrid)) return false;
if (fromMapGrid != null && fromMapGrid === dropGridIndex) return false;
if (!this._bagSession) return false;
const tile = this._bagSession.merge.map.getTile(dropGridIndex);
if (!tile) return false;
return this._willElementMerge(source, tile);
}
/**
* 地图落子升级预检:同组合内互拖不拦截(交给 Merge 交换);不能升级也不在此拦截。
*/
private _rejectMapDropUpgrade(
_source: { weapon2Id: number; weapon1Id?: number; level?: number },
_dropGridIndex: number,
_fromMapGrid?: number,
): PlaceRejectReason | null {
return null;
}
/**
* 该地图格是否处于降级禁合成。
* @param gridIndex 格
*/
private _isMapMergeBlocked(gridIndex: number): boolean {
return this._bagSession?.isMergeUpgradeBlocked(gridIndex) === true;
}
/**
* 五行同类型同等级会在背包合成(含满级拒绝),不走交换。
* @param src 拖来的字
* @param dst 目标格上的字
*/
/**
* 两枚五行字是否同 type 同级且未满级(可合成)。
* @param src 源
* @param dst 目标
*/
private _willElementMerge(src: { weapon2Id: number; level?: number }, dst: { weapon2Id: number; level?: number }): boolean {
WeaponComposeConfigProvider.instance.load();
const provider = WeaponComposeConfigProvider.instance;
const typeA = Math.floor(provider.getWeapon2Type(src.weapon2Id));
const typeB = Math.floor(provider.getWeapon2Type(dst.weapon2Id));
if (!isElementGlyphType(typeA) || !isElementGlyphType(typeB) || typeA !== typeB) return false;
const lv = Math.floor(src.level ?? 1);
if (lv !== Math.floor(dst.level ?? 1)) return false;
return lv < provider.getElementMaxLevel(typeA);
}
/** prep type3 拖拽 ghost 不在 slot/map 索引表里,需主动销毁以免挡住后续触摸 */
private _disposeEphemeralDragGrid(grid: TextGrid, source: DragSource | undefined): void {
if (!grid?.node?.isValid || !source) return;
const tracked =
(source.kind === 'bag' && this._slotGrids.has(source.slotIndex))
|| (source.kind === 'map' && this._mapGrids.has(source.gridIndex));
if (!tracked) {
this._recycleTextGridNode(grid.node);
}
}
/** drop 成功后 rebuild 可能已复用 drag 节点到新格,勿再 destroy */
private _disposeDragGridIfOrphan(grid: TextGrid | null | undefined): void {
if (!grid?.node?.isValid) return;
if (!grid.node.parent) return;
if (this._isCommittedCellGrid(grid)) return;
const mapIdx = this._resolveMapGridIndexForTextGrid(grid);
const onBag = this._isNodeUnderBagList(grid.node);
if (mapIdx != null && !onBag && this._bagSession?.merge.map.hasTile(mapIdx)) {
this._commitMapTextGridView(mapIdx, grid);
if (this._mapGrids.get(mapIdx) === grid) return;
}
this._forceRecycleTextGrid(grid);
}
/**
* 该字块是否已是槽/地图格登记的那一块。
* 交换飞行列表里的旧字块不算,落地后必须回池。
* @param grid 字块
*/
private _isCommittedCellGrid(grid: TextGrid): boolean {
const loc = grid.location;
if (loc?.kind === 'bag') return this._slotGrids.get(loc.slotIndex) === grid;
if (loc?.kind === 'map') return this._mapGrids.get(loc.gridIndex) === grid;
const slotIdx = mapKeysToArray(this._slotGrids);
for (let i = 0; i < slotIdx.length; i++) {
if (this._slotGrids.get(slotIdx[i]) === grid) return true;
}
const mapIdx = mapKeysToArray(this._mapGrids);
for (let i = 0; i < mapIdx.length; i++) {
if (this._mapGrids.get(mapIdx[i]) === grid) return true;
}
return false;
}
/**
* 背包或地图上的字拖到空装饰格:飞到 grid17 后消失,不留在格子上。
* @param grid 被拖的字
* @param drag 本次拖拽
*/
private _discardGlyphToBlank(grid: TextGrid, drag: NonNullable<typeof this._activeDrag>): void {
const session = this._bagSession;
const blank = this._blankLandNode();
const layer = this._dragLayer;
if (!session || !blank?.isValid || !layer?.isValid || !grid.node?.isValid) {
this._discardGlyphData(drag);
if (grid.node?.isValid) this._forceRecycleTextGrid(grid);
return;
}
const from: GridDetachSource = drag.source.kind === 'bag'
? { kind: 'bag', slotIndex: drag.source.slotIndex }
: { kind: 'map', gridIndex: drag.mapDrag?.fromGridIndex ?? drag.source.gridIndex };
const home = from.kind === 'bag'
? this._bagSlotNodeAt(from.slotIndex)
: this._mapPlaceAttach(from.gridIndex).attachParent;
const startWorld = grid.node.worldPosition.clone();
this._mapDropAnimInflight++;
this._detachGridForFlight(grid, from);
grid.node.parent = layer;
grid.node.setWorldPosition(startWorld);
grid.preparePlaceFlightVisual();
grid.enableDrag(false);
if (!this._discardGlyphData(drag)) {
if (this._mapDropAnimInflight > 0) this._mapDropAnimInflight--;
this._grids.removeFromFlight(grid);
if (from.kind === 'bag' && home?.isValid) {
grid.node.parent = home;
grid.node.setPosition(0, 0, 0);
} else if (from.kind === 'map') {
this._placeMapTextGrid(grid, from.gridIndex);
}
this._lineDragCancel(grid, drag);
return;
}
const target = blank.worldPosition.clone();
BagToMapPlaceAnimator.play(grid, target, layer, () => {
this._finishDiscardFlight(grid);
}, { showTrail: DeviceProfile.enableBoardPlaceTrail });
const seq = drag.debugSeq ?? this._textDragDebugSeq;
if (seq) BagTextDragDebugLog.end(seq, grid, 'discardBlank');
this._textDragDebugSeq = 0;
}
/**
* 删掉拖来的背包槽或地图字,不退回字池。
* @param drag 本次拖拽
*/
private _discardGlyphData(drag: NonNullable<typeof this._activeDrag>): boolean {
const session = this._bagSession;
if (!session) return false;
if (drag.source.kind === 'bag') return session.discardBagSlot(drag.source.slotIndex);
const mapIndex = drag.mapDrag?.fromGridIndex ?? drag.source.gridIndex;
return session.discardMapGlyph(mapIndex);
}
/**
* 飞到空装饰格后回收字块,并补上推迟的盘面刷新。
* @param grid 飞行中的字
*/
private _finishDiscardFlight(grid: TextGrid): void {
this._grids.removeFromFlight(grid);
if (grid?.node?.isValid) this._forceRecycleTextGrid(grid);
if (this._mapDropAnimInflight > 0) this._mapDropAnimInflight--;
this._flushAllPendingRebuilds();
this._refreshDragEnabled();
this._refreshCanUpgradeHints();
}
/** 空装饰格节点(grid17) */
private _blankLandNode(): Node | null {
const list = this._bagList;
if (!list?.isValid) return null;
const children = list.children;
for (let i = 0; i < children.length; i++) {
const index = TextListLandHost.indexOfNode(children[i]);
if (index != null && this._land.isBlank(index)) return children[i];
}
return null;
}
/**
* 未解锁格吃掉拖放。待开启格放行,走同类型同等级合成。
* 背包槽范围外的格子不落子,避免写入无效槽。
* @param slotIndex 命中的格子下标
*/
private _interceptLandDrop(slotIndex: number): boolean {
const state = this._land.stateOf(slotIndex);
if (state === TextListCellState.Blank || state === TextListCellState.Locked) return true;
if (state === TextListCellState.OpenNoDrag) return false;
const slotCount = this._bagSession?.merge.bag.slotCount ?? 0;
if (slotIndex >= 0 && slotIndex < slotCount) return false;
return this._land.stateOf(slotIndex) !== TextListCellState.Open;
}
private _resolveBagSlotAt(uiPos: { x: number; y: number }): number | null {
return this._resolveBagSlotAtUiPos(uiPos);
}
/** 屏幕坐标 → 背包槽(优先命中 TextGrid,再命中槽位节点) */
private _resolveBagSlotAtUiPos(uiPos: { x: number; y: number }): number | null {
if (!this._bagList) return null;
for (const [slotIndex, grid] of this._slotGrids) {
if (!grid?.node?.isValid) continue;
if (BagSlotEntranceAnimator.isPlaying(grid)) continue;
if (!this._isNodeUnderBagList(grid.node)) continue;
if (this._hitNode(grid.node, uiPos)) return slotIndex;
}
const slots = this._bagList.children;
for (let i = 0; i < slots.length; i++) {
const landIndex = TextListLandHost.indexOfNode(slots[i]);
if (landIndex == null) continue;
const occupant = this._slotGrids.get(landIndex);
if (occupant && BagSlotEntranceAnimator.isPlaying(occupant)) continue;
if (this._hitNode(slots[i], uiPos)) {
return landIndex;
}
}
return null;
}
private _resolveDropTarget(event: EventTouch, source: DragSource): DropTarget {
const uiPos = event.getUILocation();
const bagSlotIndex = this._resolveBagSlotAt(uiPos);
if (bagSlotIndex != null) {
return { kind: 'bag', slotIndex: bagSlotIndex };
}
const mapIndex = this._resolveMapGridAt(uiPos);
if (mapIndex != null) {
return { kind: 'map', gridIndex: mapIndex };
}
return dragSourceToDefaultTarget(source);
}
/** 以 TextGrid 实际挂接的背包槽为准(地图格也叫 gridN,必须先确认挂在 ui_textList 下) */
private _resolveBagSlotIndexForTextGrid(grid: TextGrid): number | null {
const parent = grid.node?.parent;
if (!parent?.isValid || !this._bagList?.isValid) return null;
if (!this._isNodeUnderBagList(grid.node)) return null;
return TextListLandHost.indexOfNode(parent);
}
/** 拖放源以节点实际挂接位置为准,location 仅作兜底 */
private _resolveDragSourceForTextGrid(grid: TextGrid): DragSource | null {
const bagSlot = this._resolveBagSlotIndexForTextGrid(grid);
if (bagSlot != null) {
return { kind: 'bag', slotIndex: bagSlot };
}
const mapIndex = this._resolveMapGridIndexForTextGrid(grid);
if (mapIndex != null) {
return { kind: 'map', gridIndex: mapIndex };
}
return this._locationToSource(grid.location);
}
private _mapDragFromIndex(drag: NonNullable<typeof this._activeDrag>): number {
if (drag.mapDrag) return drag.mapDrag.fromGridIndex;
return drag.source.kind === 'map' ? drag.source.gridIndex : -1;
}
/** 以 TextGrid 实际挂接的 MapGrid 为准,避免 rebuild 后 location 仍指向旧格(如王母时的「王」格) */
private _resolveMapGridIndexForTextGrid(grid: TextGrid): number | null {
if (this._resolveBagSlotIndexForTextGrid(grid) != null) {
return null;
}
if (!this._map) {
return grid.location?.kind === 'map' ? grid.location.gridIndex : null;
}
const parent = grid.node?.parent;
if (parent?.isValid) {
const grids = this._map.mapGrids;
if (Array.isArray(grids)) {
for (const mg of grids) {
if (mg?.node === parent) return mg.Index;
}
}
}
for (const [idx, g] of this._mapGrids) {
if (g === grid) return idx;
}
return grid.location?.kind === 'map' ? grid.location.gridIndex : null;
}
private _resolveMapGridAt(uiPos: { x: number; y: number }): number | null {
if (!this._map) return null;
const grids = this._map.mapGrids;
if (!Array.isArray(grids)) return null;
const p = new Vec2(uiPos.x, uiPos.y);
let bestIndex: number | null = null;
let bestScore = -Infinity;
for (const mg of grids) {
if (!mg?.node?.isValid) continue;
const ut = mg.node.getComponent(UITransform);
if (!ut) continue;
const rect = ut.getBoundingBoxToWorld();
if (!rect.contains(p)) continue;
const center = rect.center;
const dist = Vec2.distance(p, center);
const empty = !this._bagSession?.merge.map.hasTile(mg.Index);
// 空格 > 更近格心 > 更靠下(与旧逻辑兼容)
const score = (empty ? 1_000_000 : 0) - dist * 10 + mg.Row;
if (score > bestScore) {
bestScore = score;
bestIndex = mg.Index;
}
}
return bestIndex;
}
private _hitNode(node: Node, uiPos: { x: number; y: number }): boolean {
const ut = node.getComponent(UITransform);
if (!ut) return false;
const rect = ut.getBoundingBoxToWorld();
return rect.contains(new Vec2(uiPos.x, uiPos.y));
}
private _locationToSource(loc: TextGridLocation): DragSource | null {
if (loc.kind === 'bag') return { kind: 'bag', slotIndex: loc.slotIndex };
if (loc.kind === 'map') return { kind: 'map', gridIndex: loc.gridIndex };
return null;
}
private _onMergeEvent(event: MergeEvent): void {
try {
this._handleMergeEvent(event);
} catch (e) {
ClientErrorReporter.report('CocosBagBoardView_mergeEvent', `${event.type}|${String(e)}`);
this._flushAllPendingRebuilds();
}
}
/** 处理 MergeSession 事件 */
private _handleMergeEvent(event: MergeEvent): void {
switch (event.type) {
case MergeEventType.BagRefreshed: {
if (this._bagEntranceArmed) {
const from = this._bagEntranceFrom;
this.disarmBagEntrance();
this.cancelActiveDrag();
// 用户刷新必须立刻重建:若仍卡在上次入场,先解开再播本轮
if (this._bagEntrancePlaying) {
BagSlotEntranceAnimator.stopAll(this._slotGrids, this._bagList, false);
this._bagEntrancePlaying = false;
const layout = this._bagList?.getComponent(Layout);
if (layout?.isValid) layout.enabled = true;
}
this._pendingSessionRebuild = false;
this._pendingBagRebuild = null;
this._rebuildBagFromSession(true, from);
} else {
this._requestBagRebuild(event.playEntrance !== false);
}
break;
}
case MergeEventType.BagSlotMoved:
if (this._shouldPlayPlaceGridSfxForBagSlot(event.toSlot)) {
BattleAudio.playPlaceGrid();
}
this._requestSessionRebuild(false);
break;
case MergeEventType.TilePlaced:
if (this._shouldPlayPlaceGridSfxForPlaced(event.weapon2Id)) {
BattleAudio.playPlaceGrid();
}
this._reportMergePlaced(event.weapon2Id);
this._requestSessionRebuild(false);
if (this._shouldPlayFallFxForPlaced(event.weapon2Id)) {
this._map?.getMapGridByIndex(event.gridIndex)?.playFallEffect();
}
this._scheduleComposeFxFlush();
break;
case MergeEventType.TileMoved:
if (this._shouldPlayPlaceGridSfxForMoved(event)) {
BattleAudio.playPlaceGrid();
}
this._requestSessionRebuild(false);
if (this._shouldPlayFallFxForMoved(event)) {
this._map?.getMapGridByIndex(event.toGrid)?.playFallEffect();
}
this._scheduleComposeFxFlush();
break;
case MergeEventType.TileReturned:
if (this._shouldPlayPlaceGridSfxForBagSlot(event.bagSlot)) {
BattleAudio.playPlaceGrid();
}
this._requestSessionRebuild(false);
this._scheduleComposeFxFlush();
break;
case MergeEventType.ComposeChanged:
this._reportNewHeroComposed(event.compositions);
this._queueNewHeroComposeFx(event.compositions);
this._vacateMissingMapTilesNow();
this._requestSessionRebuild(false);
this._scheduleComposeFxFlush();
break;
case MergeEventType.BagMerged:
// 待开启格吃到同类型同等级:自身变开启,四周未解锁格变待开启并各生成一个字
if (this._land.stateOf(event.slotIndex) === TextListCellState.OpenNoDrag) {
const promoted = this._land.promoteAround(event.slotIndex);
const rolls = this._bagSession
? this._bagSession.fillGateUnlockSlots(promoted.born, promoted.levelAdd)
: [];
this._land.printUnlock(rolls);
this._fillMissingBagSlotGrids();
}
this._reportMergeUpgraded(event.weapon1Id, event.level);
if (this._dropSyncPhase === 'awaitingCommit') {
this._deferredBagUpgradeSlot = event.slotIndex;
break;
}
if (this._applyBagUpgradeInPlace(event.slotIndex, event.level)) {
this._sanitizeBagSlotAfterMerge(event.slotIndex);
if (!this._suppressDefaultUpgradeFx) {
this._playBagUpgradeEffect(event.slotIndex);
}
this._refreshCanUpgradeHints();
break;
}
this._requestBagRebuild(false);
if (!this._suppressDefaultUpgradeFx) {
this._playBagUpgradeEffect(event.slotIndex);
}
break;
case MergeEventType.MapUpgraded:
this._reportMergeUpgraded(event.weapon1Id, event.level);
if (this._dropSyncPhase === 'awaitingCommit') {
this._deferredMapUpgradeGrid = event.gridIndex;
break;
}
if (this._applyMapUpgradeInPlace(event.gridIndex, event.level)) {
this._refreshHeroLevelAtGrid(event.gridIndex, event.level);
if (this._deploySync && this._map.gridsInitialized) {
this._deploySync.reconcileFromMerge('mapUpgraded');
}
if (!this._suppressDefaultUpgradeFx) {
this._playMapUpgradeEffect(event.gridIndex);
}
this._refreshCanUpgradeHints();
this._refreshSkillBindings();
break;
}
this._requestSessionRebuild(false);
if (!this._suppressDefaultUpgradeFx) {
this._playMapUpgradeEffect(event.gridIndex);
}
this._scheduleComposeFxFlush();
break;
case MergeEventType.GlyphRewritten:
if (event.target.kind === 'bag') {
if (this._applyBagGlyphRewriteInPlace(event.target.slotIndex)) {
this._refreshCanUpgradeHints();
break;
}
this._requestBagRebuild(false);
break;
}
if (this._applyMapGlyphRewriteInPlace(event.target.gridIndex)) {
this._refreshCanUpgradeHints();
this._refreshSkillBindings();
} else {
this._requestSessionRebuild(false);
this._scheduleComposeFxFlush();
}
if (this._deploySync && this._map.gridsInitialized) {
this._deploySync.reconcileFromMerge('glyphRewritten');
}
break;
case MergeEventType.PlaceRejected:
if (event.reason === PlaceRejectReason.NotPrepPhase) {
this._refreshDragEnabled();
}
break;
}
}
/** 文字 / 格子放下(落图、入背包槽等) */
private _reportChapterInLevel(suffix: string): void {
const curWaveNum = gg.game.CurentBattle.CurentWaveNum;
gg.sdk.reportDY('inLevel', `${gg.game.getReportDYBattleCountLabel(curWaveNum)}-${suffix}`);
}
private _getWeapon1ReportName(weapon1Id: number | null | undefined): string {
const id = Number(weapon1Id);
if (!Number.isFinite(id) || id <= 0) return '未知';
const w1 = gg.data.project.getWeaponData(id);
const raw = w1?.name ?? '';
return raw ? gg.lang.get(raw) : String(id);
}
private _getWeapon2ReportName(weapon2Id: number): string {
const w2 = WeaponComposeConfigProvider.instance.getWeapon2Config(weapon2Id);
const raw = w2 ? textDisplayName(w2) : '';
return raw ? gg.lang.get(raw) : String(weapon2Id);
}
private _reportMergePlaced(weapon2Id: number): void {
if (weapon2Id === GRID_WEAPON2_ID) {
this._placedGridCount++;
this._reportChapterInLevel(`放置格子${this._placedGridCount}`);
return;
}
const w2Type = WeaponComposeConfigProvider.instance.getWeapon2Type(weapon2Id) as Weapon2TileType;
if (this._isMapTextGlyphType(w2Type)) {
this._reportChapterInLevel(`放置文字-${this._getWeapon2ReportName(weapon2Id)}`);
}
}
private _reportMergeUpgraded(weapon1Id: number, level: number): void {
this._reportChapterInLevel(`合成-${this._getWeapon1ReportName(weapon1Id)}${level}级`);
}
private _reportNewHeroComposed(compositions: readonly ComposedUnit[]): void {
if (!this._composeKeysSeeded) return;
for (const c of compositions) {
if (c.gridIndices.length <= 1) continue;
const key = this._composeKey(c);
if (this._prevComposeKeys.has(key)) continue;
HeadIconManager.onHeroComposed(c.weapon1Id);
this._reportChapterInLevel(`组成-${this._getWeapon1ReportName(c.weapon1Id)}武将`);
}
}
/** 文字 / 格子放下(落图、入背包槽等) */
private _shouldPlayPlaceGridSfxForPlaced(weapon2Id: number): boolean {
if (weapon2Id === GRID_WEAPON2_ID) return true;
const w2Type = WeaponComposeConfigProvider.instance.getWeapon2Type(weapon2Id) as Weapon2TileType;
return this._isMapTextGlyphType(w2Type);
}
/** 文字首次落到空格:播放 mapGrid.fallSpine */
private _shouldPlayFallFxForPlaced(weapon2Id: number): boolean {
if (weapon2Id === GRID_WEAPON2_ID) return false;
const w2Type = WeaponComposeConfigProvider.instance.getWeapon2Type(weapon2Id) as Weapon2TileType;
return this._isMapTextGlyphType(w2Type);
}
/** 地图文字类 Weapon2(五行字或遗留 type1 单字) */
private _isMapTextGlyphType(w2Type: Weapon2TileType): boolean {
return isElementGlyphType(w2Type) || w2Type === Weapon2TileType.Single;
}
/**
* 地图文字移到空格:源格已空、目标格有字。
* 交换(两格仍有字)、同格、type3 扩格换位不播。
*/
private _shouldPlayFallFxForMoved(event: MergeEvent): boolean {
if (event.type !== MergeEventType.TileMoved) return false;
if (event.fromGrid === event.toGrid || !this._bagSession) return false;
const map = this._bagSession.merge.map;
if (map.hasTile(event.fromGrid) || !map.hasTile(event.toGrid)) return false;
const tile = map.getTile(event.toGrid);
if (!tile) return false;
return this._shouldPlayFallFxForPlaced(tile.weapon2Id);
}
/** 拖到背包槽放下(槽间移动/互换、地图收回) */
private _shouldPlayPlaceGridSfxForBagSlot(slotIndex: number): boolean {
const slot = this._bagSession?.getBagSlots()[slotIndex];
return !!slot && this._shouldPlayPlaceGridSfxForPlaced(slot.weapon2Id);
}
/** 地图内拖动后落格(含 type3 换位、文字移格/交换、与背包互换) */
private _shouldPlayPlaceGridSfxForMoved(event: MergeEvent): boolean {
if (event.type !== MergeEventType.TileMoved || !this._bagSession) return false;
const map = this._bagSession.merge.map;
if (map.isPrepType3(event.toGrid) && !map.hasTile(event.toGrid)) {
return true;
}
const tile = map.getTile(event.toGrid);
if (!tile) return false;
return this._shouldPlayPlaceGridSfxForPlaced(tile.weapon2Id);
}
private _rebuildFromSession(
playBagEntrance = false,
fromWorld?: Readonly<Vec3> | null,
): void {
if (!this._bagSession || !this._map) return;
if (this._rebuildInProgress) {
this._pendingSessionRebuild = true;
return;
}
this._rebuildInProgress = true;
try {
this._rebuildFromSessionCore(playBagEntrance, fromWorld);
} finally {
this._rebuildInProgress = false;
}
}
/** 仅重建背包槽;地图 _mapGrids 与 reconcile 均不触碰 */
private _rebuildBagFromSession(
playBagEntrance = false,
fromWorld?: Readonly<Vec3> | null,
): void {
if (!this._bagSession || !this._bagList) return;
if (this._rebuildInProgress) {
this._pendingBagRebuild = { playEntrance: playBagEntrance, fromWorld };
return;
}
this._rebuildInProgress = true;
try {
this._rebuildBagFromSessionCore(playBagEntrance, fromWorld);
} finally {
this._rebuildInProgress = false;
}
}
private _rebuildBagFromSessionCore(
playBagEntrance = false,
fromWorld?: Readonly<Vec3> | null,
): void {
if (!this._bagSession || !this._bagList) return;
this._unlockGuide.clear();
this._invalidatePlaceFlights();
this._clearTextSelect();
this._bagEntrancePlaying = playBagEntrance;
BagSlotEntranceAnimator.stopAll(this._slotGrids, this._bagList, false);
if (playBagEntrance) {
this._warmTextGridPool();
}
if (!playBagEntrance) {
const layout = this._bagList?.getComponent(Layout);
if (layout?.isValid) layout.enabled = true;
}
const prevSlots = this._grids.slotGrids;
this._grids.replaceSlotGrids(new Map());
const reservedGrids = createMembershipSet<TextGrid>();
this._collectMapProtectedGrids(reservedGrids);
this._assignBagSlotsFromSession(
prevSlots,
playBagEntrance,
reservedGrids,
[...this._grids.flightGrids],
);
for (const g of prevSlots.values()) {
if (g?.node?.isValid && !reservedGrids.has(g)) {
this._recycleTextGridNode(g.node);
}
}
this._fillMissingBagSlotGrids(reservedGrids);
this._reconcileMapGridTracking();
const shouldPlayEntrance = playBagEntrance && this._slotGrids.size > 0;
if (playBagEntrance && !shouldPlayEntrance) {
this._bagEntrancePlaying = false;
} else if (shouldPlayEntrance) {
this._bagEntrancePlaying = true;
}
this._refreshDragEnabled();
if (shouldPlayEntrance) {
this._playBagEntrance(fromWorld);
} else {
this._refreshCanUpgradeHints();
this._flushAllPendingRebuilds();
}
}
/**
* 按 session 填充 _slotGrids。
* reservedGrids 含场上字块时,禁止把它们挪进背包。
*/
private _assignBagSlotsFromSession(
prevSlots: ReadonlyMap<number, TextGrid>,
playBagEntrance: boolean,
reservedGrids: MembershipSet<TextGrid>,
flightGrids: readonly TextGrid[],
prevMap?: ReadonlyMap<number, TextGrid>,
): void {
if (!this._bagSession || !this._bagList) return;
const slots = this._bagSession.getBagSlots();
const bagNeedSpawn: number[] = [];
const mutableFlight = [...flightGrids];
for (let i = 0; i < slots.length; i++) {
const record = slots[i];
const slotNode = this._bagSlotNodeAt(i);
if (!record || !slotNode) {
if (record && !slotNode) {
console.log('[CocosBagBoardView] 背包槽节点缺失', 'slot=', i, 'weapon2Id=', record.weapon2Id);
}
if (slotNode?.isValid) {
let inflightKeep: TextGrid | undefined;
for (const g of mutableFlight) {
if (g?.node?.isValid && g.node.parent === slotNode) {
inflightKeep = g;
break;
}
}
this._purgeTextGridsOnNode(slotNode, inflightKeep);
}
this._slotGrids.delete(i);
continue;
}
this._purgeUntrackedBagSlotGrids(slotNode, i);
const entity = buildTextGridEntityData(
{ kind: 'bag', slotIndex: i },
record,
{ player: this._battle?.player ?? gg.game.localPlayer },
);
if (!entity) {
console.log(
'[CocosBagBoardView] 背包槽无法构建实体(字表缺失?)',
'slot=', i,
'weapon2Id=', record.weapon2Id,
);
this._purgeTextGridsOnNode(slotNode);
bagNeedSpawn.push(i);
continue;
}
const weapon2Id = Number(entity.tile.id);
const level = entity.level;
let reuseCandidate = prevSlots.get(i);
if (!this._canReuseTextGridForBag(reuseCandidate, prevMap)) {
reuseCandidate = undefined;
}
if (!reuseCandidate) {
const picked = this._pickReusableTextGridByWeapon2(
mutableFlight, weapon2Id, level, reservedGrids, false,
);
reuseCandidate = this._canReuseTextGridForBag(picked, prevMap) ? picked : undefined;
}
if (reuseCandidate && reservedGrids.has(reuseCandidate)) {
reuseCandidate = undefined;
}
const sameSlotReuse = reuseCandidate != null && prevSlots.get(i) === reuseCandidate;
let grid: TextGrid | null = null;
try {
grid = this._tryReuseTextGrid(
reuseCandidate, entity, slotNode, mutableFlight, reservedGrids, sameSlotReuse,
);
if (!grid) {
this._purgeTextGridsOnNode(slotNode);
grid = this._spawnTextGrid(entity, slotNode, !playBagEntrance);
}
} catch (e) {
console.log('[CocosBagBoardView] 背包槽构建异常', 'slot=', i, 'weapon2Id=', weapon2Id, String(e));
grid = null;
}
if (grid) {
this._slotGrids.set(i, grid);
membershipAdd(reservedGrids, grid);
} else {
bagNeedSpawn.push(i);
}
}
if (bagNeedSpawn.length > 0) {
const unusedBag: TextGrid[] = [];
for (const g of prevSlots.values()) {
if (!this._canReuseTextGridForBag(g, prevMap)) continue;
if (!reservedGrids.has(g)) unusedBag.push(g);
}
for (let n = 0; n < bagNeedSpawn.length; n++) {
const i = bagNeedSpawn[n];
const record = slots[i];
const slotNode = this._bagSlotNodeAt(i);
if (!record || !slotNode?.isValid) continue;
const entity = buildTextGridEntityData(
{ kind: 'bag', slotIndex: i },
record,
{ player: this._battle?.player ?? gg.game.localPlayer },
);
if (!entity) continue;
let grid: TextGrid | null = null;
const donor = unusedBag.length > 0 ? unusedBag.pop()! : undefined;
try {
if (donor) {
grid = this._tryReuseTextGrid(
donor, entity, slotNode, mutableFlight, reservedGrids, true,
);
}
if (!grid) {
this._purgeTextGridsOnNode(slotNode);
grid = this._spawnTextGrid(entity, slotNode, !playBagEntrance);
}
} catch (e) {
console.log('[CocosBagBoardView] 背包空槽补建异常', 'slot=', i, String(e));
grid = null;
}
if (grid) {
this._slotGrids.set(i, grid);
membershipAdd(reservedGrids, grid);
} else {
console.log(
'[CocosBagBoardView] 背包槽 TextGrid 生成失败',
'slot=', i,
'weapon2Id=', record.weapon2Id,
);
}
}
}
}
private _rebuildFromSessionCore(
playBagEntrance = false,
fromWorld?: Readonly<Vec3> | null,
): void {
if (!this._bagSession || !this._map) return;
this._unlockGuide.clear();
// 先作废飞行中的 drop 回调,再改盘面;否则 onAnimDone 会对新背包再 drop → 空槽
this._invalidatePlaceFlights();
if (this._deploySync && this._map.gridsInitialized) {
this._deploySync.reconcileFromMerge('rebuild');
}
this._clearTextSelect();
this._bagEntrancePlaying = playBagEntrance;
// settle=false:本帧即将重建/再播入场,勿触发旧 finish 造成二次 rebuild
BagSlotEntranceAnimator.stopAll(this._slotGrids, this._bagList, false);
if (playBagEntrance) {
this._warmTextGridPool();
}
if (!playBagEntrance) {
const layout = this._bagList?.getComponent(Layout);
if (layout?.isValid) layout.enabled = true;
}
// 同槽/同格同 weapon2 复用 TextGrid,避免升级/移动重建时 destroy → abort 打断大招
const prevSlots = this._grids.slotGrids;
const prevMap = this._grids.mapGrids;
this._grids.replaceSlotGrids(new Map());
this._grids.replaceMapGrids(new Map());
const flightGrids = this._grids.takeFlightGrids();
/** 背包/地图重建全程共享:已挂接的 TextGrid 不可再被 prevMap 全局搜索复用 */
const usedTextGrids = createMembershipSet<TextGrid>();
this._collectMapProtectedGrids(usedTextGrids, prevMap);
this._assignBagSlotsFromSession(
prevSlots, playBagEntrance, usedTextGrids, flightGrids, prevMap,
);
// 放到地图后再刷新:空出来的槽没有 prev,优先搬「闲置的旧背包节点」过来,禁止先 recycle 再 getNode 失败留空
// (空槽补建已在 _assignBagSlotsFromSession 内完成)
// 背包补建后、地图复用前:回收已放弃的旧背包节点,降低同帧 textGrid 峰值导致空槽
for (const g of prevSlots.values()) {
if (g?.node?.isValid && !usedTextGrids.has(g)) {
this._recycleTextGridNode(g.node);
}
}
// 地图重建前补齐:否则 prev 节点会被地图复用,背包槽 getNode 失败留空
this._fillMissingBagSlotGrids(usedTextGrids);
for (const g of this._slotGrids.values()) {
if (g?.isValid) membershipAdd(usedTextGrids, g);
}
this._bagSession.merge.map.forEachTile((gridIndex, tile) => {
const mapGrid = this._map!.getMapGridByIndex(gridIndex);
if (!mapGrid?.node) return;
const composition = this._bagSession!.compositions.find(
c => c.gridIndices.includes(gridIndex),
) ?? null;
const entity = buildTextGridEntityData(
{ kind: 'map', gridIndex },
tile,
{ composition, player: this._battle?.player ?? gg.game.localPlayer },
);
if (!entity) return;
const weapon2Id = Number(entity.tile.id);
const level = entity.level;
const cellParent = mapGrid.node;
const mapParent = this._mapTextLayer() ?? cellParent;
const onCell = this._mapGridOnCell(gridIndex);
let reuseCandidate: TextGrid | undefined;
if (onCell && !usedTextGrids.has(onCell)) {
reuseCandidate = this._rejectBagHostedGrid(onCell);
}
if (!reuseCandidate) {
reuseCandidate = this._rejectBagHostedGrid(this._findFlightGridForMap(gridIndex, flightGrids) ?? undefined);
}
const atCell = prevMap.get(gridIndex);
if (
!reuseCandidate
&& atCell
&& atCell.weapon2Id === weapon2Id
&& !usedTextGrids.has(atCell)
) {
reuseCandidate = this._rejectBagHostedGrid(atCell);
}
if (!reuseCandidate && !onCell) {
reuseCandidate = this._rejectBagHostedGrid(this._findPrevMapTextGridByWeapon2Id(
prevMap, weapon2Id, gridIndex, usedTextGrids, level,
));
}
if (!reuseCandidate && !onCell) {
reuseCandidate = this._rejectBagHostedGrid(this._pickReusableTextGridByWeapon2(
flightGrids, weapon2Id, level, usedTextGrids, false,
));
}
let grid: TextGrid | null = this._tryReuseTextGrid(
reuseCandidate, entity, mapParent, flightGrids, usedTextGrids,
);
if (!grid && !this._mapGridOnCell(gridIndex) && !this._findFlightGridForMap(gridIndex, flightGrids)) {
grid = this._spawnTextGrid(entity, mapParent);
}
if (grid) {
this._mapGrids.set(gridIndex, grid);
membershipAdd(usedTextGrids, grid);
}
});
for (const g of prevSlots.values()) {
if (g?.node?.isValid && !this._isTextGridTracked(g)) this._recycleTextGridNode(g.node);
}
for (const g of prevMap.values()) {
if (g?.node?.isValid && !this._isTextGridTracked(g)) this._recycleTextGridNode(g.node);
}
for (const g of flightGrids) {
if (g?.node?.isValid && !this._isTextGridTracked(g)) this._recycleTextGridNode(g.node);
}
this._purgeOrphanTextGridsOnMap();
this._clearPrepType3Surfaces();
// 地图 rebuild 可能误搬走背包槽上的字块:按 session 强制对齐父节点并补建
this._fillMissingBagSlotGrids(usedTextGrids);
// 入场延到 rebuild + 背包修复之后,避免播放入场时槽位仍为空
const shouldPlayEntrance = playBagEntrance && this._slotGrids.size > 0;
if (playBagEntrance && !shouldPlayEntrance) {
this._bagEntrancePlaying = false;
} else if (shouldPlayEntrance) {
this._bagEntrancePlaying = true;
}
this._bindPrepType3Cells();
this._refreshDragEnabled();
this._refreshSkillBindings();
this._rebuildEnemyMarchPathCache();
// 背包入场必须在地图重建之后启动,否则同帧 stop tween 会卡住入场锁并留下「空槽」观感
if (shouldPlayEntrance) {
this._playBagEntrance(fromWorld);
} else {
this._refreshCanUpgradeHints();
this._flushAllPendingRebuilds();
}
}
/** DeploySync / TextGrid 重建后同步 SkillController → HeroActor */
private _refreshSkillBindings(): void {
const sc = this._battle?.player?.skillController as SkillController | null | undefined;
const map = this._map;
if (!sc || !map) return;
sc.refreshEntitySkillBindings(map, this._mapGrids);
}
/**
* 交换/移动后按 weapon2Id 在 prevMap 中找可复用 TextGrid。
* 同字异级时优先匹配 level,避免认领错块后清掉另一块的等级标签。
*/
private _findPrevMapTextGridByWeapon2Id(
prevMap: ReadonlyMap<number, TextGrid>,
weapon2Id: number,
preferGridIndex: number,
used: MembershipSet<TextGrid>,
preferLevel?: number,
): TextGrid | undefined {
let sameIndex: TextGrid | undefined;
let levelMatch: TextGrid | undefined;
let fallback: TextGrid | undefined;
for (const [idx, grid] of prevMap) {
if (used.has(grid) || grid.weapon2Id !== weapon2Id) continue;
if (idx === preferGridIndex) {
sameIndex = grid;
break;
}
if (preferLevel != null && grid.level === preferLevel && !levelMatch) {
levelMatch = grid;
}
if (!fallback) fallback = grid;
}
return sameIndex ?? levelMatch ?? fallback;
}
/** 飞行中已挂到目标父节点的 TextGrid(交换动画 attachParent 后) */
private _pickFlightTextGridOnParent(
flightGrids: readonly TextGrid[],
parent: Node,
weapon2Id: number,
preferLevel: number,
used: MembershipSet<TextGrid>,
): TextGrid | undefined {
let levelMiss: TextGrid | undefined;
for (const g of flightGrids) {
if (!g?.node?.isValid || used.has(g) || g.weapon2Id !== weapon2Id) continue;
if (g.node.parent !== parent) continue;
if (g.level === preferLevel) return g;
if (!levelMiss) levelMiss = g;
}
return levelMiss;
}
/**
* 从列表中按 weapon2Id 认领可复用 TextGrid;preferLevel 优先。
* @param removeFromList true 时从数组 splice(orphanMapGrids)
*/
private _pickReusableTextGridByWeapon2(
list: TextGrid[],
weapon2Id: number,
preferLevel: number,
used: MembershipSet<TextGrid>,
removeFromList: boolean,
): TextGrid | undefined {
let levelIdx = -1;
let anyIdx = -1;
for (let i = 0; i < list.length; i++) {
const g = list[i];
if (!g?.node?.isValid || used.has(g) || g.weapon2Id !== weapon2Id) continue;
if (g.level === preferLevel) {
levelIdx = i;
break;
}
if (anyIdx < 0) anyIdx = i;
}
const idx = levelIdx >= 0 ? levelIdx : anyIdx;
if (idx < 0) return undefined;
const picked = list[idx];
if (removeFromList) list.splice(idx, 1);
return picked;
}
/** 同 weapon2 复用节点并刷新数据;父节点变化时重新挂接。allowIdChange:背包刷新同槽换字时仍复用节点 */
private _tryReuseTextGrid(
existing: TextGrid | undefined,
entity: TextGridEntityData,
parent: Node,
flightGrids?: readonly TextGrid[],
usedTextGrids?: MembershipSet<TextGrid>,
allowIdChange = false,
): TextGrid | null {
if (!existing?.node?.isValid) return null;
if (this._isNodeUnderBagList(existing.node) && !this._isNodeUnderBagList(parent)) {
return null;
}
const occupant = parent === this._textGridLayer || parent === this._dragLayer
? null
: this._grids.findMapGridOnCell(parent);
if (occupant && occupant !== existing) {
return null;
}
BagSlotEntranceAnimator.stopGrid(existing, false);
if (!allowIdChange && existing.weapon2Id !== Number(entity.tile.id)) return null;
if (existing.node.parent !== parent) {
existing.node.parent = parent;
existing.node.setPosition(0, 0, 0);
}
if (entity.location.kind === 'map') {
this._placeMapTextGrid(existing, entity.location.gridIndex);
}
existing.node.active = true;
const uo = existing.node.getComponent(UIOpacity);
if (uo) uo.opacity = 255;
this._writeGridData(existing, entity);
existing.rememberOrigin();
existing.node.name = 'textGrid';
existing.enableDrag(
entity.location.kind === 'bag'
? this._canEnableBagGridDrag(existing)
: this._canDrag() && !BagSlotEntranceAnimator.isPlaying(existing),
);
this._ensureMapClickHandler(existing);
return existing;
}
private _isTextGridTracked(grid: TextGrid): boolean {
return this._grids.isTracked(grid, (g) => this._isMapHostedTextGrid(g));
}
/** 清掉背包/地图/飞行中全部升级箭头(含未纳入索引的叠字残留) */
private _clearAllCanUpgradeHints(): void {
for (const g of this._slotGrids.values()) g.setCanUpgradeHint(false);
for (const g of this._mapGrids.values()) g.setCanUpgradeHint(false);
for (const g of this._grids.flightGrids) g.setCanUpgradeHint(false);
if (this._bagList?.isValid) {
for (const slot of this._bagList.children) {
if (!slot?.isValid) continue;
for (const child of slot.children) {
child.getComponent(TextGrid)?.setCanUpgradeHint(false);
}
}
}
const cells = this._map?.mapGrids;
if (cells?.length) {
for (const cell of cells) {
const parent = cell?.node;
if (!parent?.isValid) continue;
for (const child of parent.children) {
child.getComponent(TextGrid)?.setCanUpgradeHint(false);
}
}
}
}
/** 索引表中的字块是否与 session 该格数据一致(不一致则不亮升级标) */
private _hintGridAt(entry: { kind: 'bag' | 'map'; index: number }): TextGrid | null {
if (!this._bagSession) return null;
const grid = entry.kind === 'bag'
? this._slotGrids.get(entry.index)
: this._mapGrids.get(entry.index);
if (!grid?.isValid) return null;
if (entry.kind === 'bag') {
const slot = this._bagSession.getBagSlots()[entry.index];
if (!slot) return null;
if (grid.weapon2Id !== slot.weapon2Id) return null;
if ((grid.level ?? 1) !== (slot.level ?? 1)) return null;
return grid;
}
const tile = this._bagSession.merge.map.getTile(entry.index);
if (!tile) return null;
if (grid.weapon2Id !== tile.weapon2Id) return null;
if ((grid.level ?? 1) !== (tile.level ?? 1)) return null;
return grid;
}
/**
* 背包/地图存在可五行同级合成时,显示 canUpEff。
* 拖动中:只显示与当前拖起字可合成的目标。
*/
private _refreshCanUpgradeHints(): void {
this._refreshSleepFx();
if (this._activeDrag) {
this._showMergeTargetShake(this._activeDrag);
return;
}
this._clearAllCanUpgradeHints();
this._refreshAreaUnlockGuide();
if (!this._bagSession) return;
WeaponComposeConfigProvider.instance.load();
const provider = WeaponComposeConfigProvider.instance;
type HintEntry = {
kind: 'bag' | 'map';
index: number;
weapon2Id: number;
elementType: number;
level: number;
};
const entries: HintEntry[] = [];
const slots = this._bagSession.getBagSlots();
for (let i = 0; i < slots.length; i++) {
const s = slots[i];
if (!s) continue;
const t = Number(provider.getWeapon2Type(s.weapon2Id));
if (!isElementGlyphType(t)) continue;
entries.push({
kind: 'bag',
index: i,
weapon2Id: s.weapon2Id,
elementType: t,
level: Math.floor(s.level ?? 1),
});
}
this._bagSession.merge.map.forEachTile((gridIndex, tile) => {
const t = Number(provider.getWeapon2Type(tile.weapon2Id));
if (!isElementGlyphType(t)) return;
entries.push({
kind: 'map',
index: gridIndex,
weapon2Id: tile.weapon2Id,
elementType: t,
level: Math.floor(tile.level ?? 1),
});
});
const markMergePair = (a: HintEntry, b: HintEntry): void => {
if (a.kind === 'map' && this._isMapMergeBlocked(a.index)) return;
if (b.kind === 'map' && this._isMapMergeBlocked(b.index)) return;
if (a.level >= provider.getElementMaxLevel(a.elementType)) return;
// 背包↔背包:至少一侧可拖;两枚待开启/不可拖不能互判可合成
if (a.kind === 'bag' && b.kind === 'bag') {
const aOpen = this._land.stateOf(a.index) === TextListCellState.Open;
const bOpen = this._land.stateOf(b.index) === TextListCellState.Open;
if (!aOpen && !bOpen) return;
}
const ga = this._hintGridAt(a);
const gb = this._hintGridAt(b);
if (!ga || !gb) return;
if (BagSlotEntranceAnimator.isPlaying(ga) || BagSlotEntranceAnimator.isPlaying(gb)) return;
ga.setCanUpgradeHint(true);
gb.setCanUpgradeHint(true);
};
for (let i = 0; i < entries.length; i++) {
const a = entries[i];
for (let j = i + 1; j < entries.length; j++) {
const b = entries[j];
if (a.elementType !== b.elementType || a.level !== b.level) continue;
markMergePair(a, b);
}
}
}
/** 回收地图「睡觉」特效(组合字玩法已移除) */
private _refreshSleepFx(): void {
for (const g of this._slotGrids.values()) g.setSleepFx(false);
for (const g of this._mapGrids.values()) g.setSleepFx(false);
}
/** 背包有可解锁当前地图区域的字时,显示虚线框与连线 */
private _refreshAreaUnlockGuide(): void {
if (!this._bagSession || !this._map) {
this._unlockGuide.clear();
return;
}
// 落子飞行 / drop 同步中禁止刷新,否则框会踩进合成中的 TextGrid 树
const skip = this._activeDrag != null
|| this._bagEntrancePlaying
|| !this._canDrag()
|| this._mapDropAnimInflight > 0
|| this._dropSyncPhase !== 'idle';
this._unlockGuide.refresh({
bagSession: this._bagSession,
map: this._map,
campAreaId: AI_VIEW_LOCAL_CAMP,
slotGridAt: (slotIndex) => this._slotGrids.get(slotIndex) ?? null,
slotNodeAt: (slotIndex) => this._bagSlotNodeAt(slotIndex),
canDragSlot: (slotIndex) => this._land.stateOf(slotIndex) === TextListCellState.Open,
skip,
});
}
/**
* 背包合成后:槽内只留一块真身,清 select,避免叠字/红框残留。
* @param slotIndex 合成目标槽
*/
private _sanitizeBagSlotAfterMerge(slotIndex: number): void {
const slotNode = this._bagSlotNodeAt(slotIndex);
if (!slotNode?.isValid) return;
const keep = this._slotGrids.get(slotIndex);
for (const child of [...slotNode.children]) {
if (!child?.isValid) continue;
if (child.name === '引导虚线框') {
if (child['resKey']) gg.res.putNode(child);
else child.destroy();
continue;
}
const tg = child.getComponent(TextGrid);
if (!tg?.isValid) continue;
if (tg === keep) continue;
this._forceRecycleTextGrid(tg);
}
if (keep?.isValid) {
keep.isSelectStatus = false;
keep.isRedSelectStatus = false;
keep.enableDrag(this._canEnableBagGridDrag(keep));
this._bindDrag(keep);
}
}
private _playBagUpgradeEffect(slotIndex: number): void {
BattleAudio.playUpgrade();
this._playLevelUpTextForBag(slotIndex);
}
/**
* 地图字块仅等级变化:原地刷新 TextGrid,避免全量 rebuild 叠字。
* @returns 是否已在原节点上更新成功
*/
private _applyMapUpgradeInPlace(gridIndex: number, newLevel: number): boolean {
if (!this._bagSession || !this._map) return false;
if (!this._bagSession.merge.map.hasTile(gridIndex)) return false;
const composition = this._bagSession.compositions.find(
c => c.gridIndices.includes(gridIndex),
) ?? null;
const indices = composition?.gridIndices.length
? composition.gridIndices.slice()
: [gridIndex];
let applied = 0;
for (let i = 0; i < indices.length; i++) {
const idx = indices[i];
const grid = this._resolveMapTextGridForUpgrade(idx);
if (!grid?.isValid) continue;
grid.applyMapLevel(newLevel);
this._grids.setMapGrid(idx, grid);
applied++;
}
return applied > 0;
}
/** 背包字块仅等级变化:原地刷新 TextGrid */
private _applyBagUpgradeInPlace(slotIndex: number, newLevel: number): boolean {
if (!this._bagSession || !this._bagList) return false;
if (!this._bagSession.getBagSlots()[slotIndex]) return false;
const slotNode = this._bagSlotNodeAt(slotIndex);
if (!slotNode?.isValid) return false;
const grid = this._resolveBagTextGridForUpgrade(slotIndex, slotNode);
if (!grid?.isValid) return false;
grid.applyMapLevel(newLevel);
this._grids.setBagGrid(slotIndex, grid);
return true;
}
/** 升级原地刷新:解析地图格上的主 TextGrid */
private _resolveMapTextGridForUpgrade(gridIndex: number): TextGrid | null {
if (!this._bagSession || !this._map) return null;
if (!this._bagSession.merge.map.hasTile(gridIndex)) return null;
const parent = this._map.getMapGridByIndex(gridIndex)?.node;
if (!parent?.isValid) return null;
let grid = this._mapGrids.get(gridIndex) ?? null;
if (!grid?.isValid) {
grid = this._mapGridOnCell(gridIndex);
}
return grid?.isValid ? grid : null;
}
/** 升级原地刷新:解析背包槽上的主 TextGrid */
private _resolveBagTextGridForUpgrade(slotIndex: number, slotNode: Node): TextGrid | null {
const record = this._bagSession?.getBagSlots()[slotIndex];
if (record) {
const live = this._resolveBagSlotLiveGrid(slotIndex, slotNode, record.weapon2Id);
if (live?.isValid) return live;
}
let grid = this._slotGrids.get(slotIndex) ?? null;
if (!grid?.isValid || grid.node.parent !== slotNode || grid.weapon2Id === 0) {
grid = this._grids.findMapGridOnCell(slotNode);
}
return grid?.isValid && grid.weapon2Id !== 0 ? grid : null;
}
/**
* 属性卡:落子成功后起播文昌笔,半程再换皮。
*/
private _tryPresentPendingAttrRewrite(): void {
const session = this._bagSession;
if (!session) return;
const pending = session.consumePendingAttrRewrite();
if (!pending) return;
const target: BattleBuffSkillDropTarget = pending.target.kind === 'bag'
? { kind: 'bag', slotIndex: pending.target.slotIndex }
: { kind: 'map', gridIndex: pending.target.gridIndex };
const weapon2Id = pending.weapon2Id;
this._rewriteGlyphFx.tryPlayAttrRewrite(target, () => {
session.commitAttrRewrite(target, weapon2Id);
});
}
/** 文昌笔:背包单字改 weapon2 后原地刷新 */
private _applyBagGlyphRewriteInPlace(slotIndex: number): boolean {
if (!this._bagSession || !this._bagList) return false;
if (!this._bagSession.getBagSlots()[slotIndex]) return false;
const slotNode = this._bagSlotNodeAt(slotIndex);
if (!slotNode?.isValid) return false;
const grid = this._resolveBagTextGridForUpgrade(slotIndex, slotNode);
if (!grid?.isValid) return false;
this._commitBagTextGridView(slotIndex, grid);
this._grids.setBagGrid(slotIndex, grid);
return true;
}
/** 文昌笔:地图单字改 weapon2 后原地刷新 */
private _applyMapGlyphRewriteInPlace(gridIndex: number): boolean {
if (!this._bagSession || !this._map) return false;
if (!this._bagSession.merge.map.hasTile(gridIndex)) return false;
const grid = this._resolveMapTextGridForUpgrade(gridIndex);
if (!grid?.isValid) return false;
this._commitMapTextGridView(gridIndex, grid);
this._grids.setMapGrid(gridIndex, grid);
return true;
}
/** 多格 HeroActor 等级皮刷新(TextGrid lv 可能被隐藏) */
private _refreshHeroLevelAtGrid(gridIndex: number, level: number): void {
const composition = this._bagSession?.compositions.find(
c => c.gridIndices.includes(gridIndex),
);
if (!composition || !isHeroActorWeapon1(composition.weapon1Id)) return;
const lv = Math.max(1, Math.floor(level) || 1);
const skin = HeroActorHost.instance.getVisualByComposition({ ...composition, level: lv });
skin?.setSkinByLevelAndQuality(lv);
}
private _playMapUpgradeEffect(gridIndex: number): void {
const composition = this._bagSession?.compositions.find(
c => c.gridIndices.includes(gridIndex),
);
const indices = composition?.gridIndices.length
? composition.gridIndices
: [gridIndex];
for (let i = 0; i < indices.length; i++) {
this._map?.getMapGridByIndex(indices[i])?.playUpgradeEffect();
}
BattleAudio.playUpgrade();
this._playLevelUpTextForMap(gridIndex, composition ?? null);
}
/** 背包单字升级飘字 */
private _playLevelUpTextForBag(slotIndex: number): void {
const grid = this._slotGrids.get(slotIndex);
if (!grid?.node?.isValid) {
console.log('[BagBoard] 背包升级飘字缺少字块', slotIndex);
return;
}
LevelUpTextFloater.play(this._dragLayer, grid.node.worldPosition);
}
/**
* 地图升级飘字:多格/英雄取组合中心,单字取字块或格子中心。
* @param gridIndex 升级格
* @param composition 当前合成体(可空)
*/
private _playLevelUpTextForMap(gridIndex: number, composition: ComposedUnit | null): void {
const world = this._resolveLevelUpTextWorldPos(gridIndex, composition);
if (!world) {
console.log('[BagBoard] 地图升级飘字无锚点', gridIndex);
return;
}
LevelUpTextFloater.play(this._dragLayer, world);
}
/** 解析升级飘字世界坐标 */
private _resolveLevelUpTextWorldPos(gridIndex: number, composition: ComposedUnit | null): Vec3 | null {
if (composition && composition.gridIndices.length > 0) {
if (isHeroActorWeapon1(composition.weapon1Id)) {
const skin = HeroActorHost.instance.getVisualByComposition(composition);
const visual = skin?.displayNode?.isValid ? skin.displayNode : skin?.node;
if (visual?.isValid) {
return visual.worldPosition.clone();
}
}
if (composition.gridIndices.length > 1 && this._map) {
let sx = 0;
let sy = 0;
let sz = 0;
let n = 0;
for (let i = 0; i < composition.gridIndices.length; i++) {
const node = this._map.getMapGridByIndex(composition.gridIndices[i])?.node;
if (!node?.isValid) continue;
const p = node.worldPosition;
sx += p.x;
sy += p.y;
sz += p.z;
n++;
}
if (n > 0) return new Vec3(sx / n, sy / n, sz / n);
}
}
const text = this._mapGrids.get(gridIndex);
if (text?.node?.isValid) return text.node.worldPosition.clone();
const mapGrid = this._map?.getMapGridByIndex(gridIndex)?.node;
if (mapGrid?.isValid) return mapGrid.worldPosition.clone();
return null;
}
private _composeKey(c: ComposedUnit): string {
return `${c.weapon1Id}_${c.gridIndices.join(',')}`;
}
/** 检测新形成的多字英雄组合,入队合成音效 */
private _queueNewHeroComposeFx(compositions: readonly ComposedUnit[]): void {
const nextKeys = createMembershipSet<string>();
for (const c of compositions) {
const key = this._composeKey(c);
membershipAdd(nextKeys, key);
if (c.gridIndices.length <= 1) continue;
if (!this._composeKeysSeeded || this._prevComposeKeys.has(key)) continue;
const idx = c.gridIndices[0];
if (!this._pendingComposeFxGrids.includes(idx)) {
this._pendingComposeFxGrids.push(idx);
this._pendingComposeHeroIds.push(c.weapon1Id);
}
}
this._prevComposeKeys = nextKeys;
this._composeKeysSeeded = true;
}
/**
* ComposeChanged 后常有 TilePlaced/Moved 再次 rebuild;
* 延后一帧再播合成音效。
*/
private _scheduleComposeFxFlush(): void {
if (this._pendingComposeFxGrids.length === 0 || this._composeFxFlushScheduled) return;
this._composeFxFlushScheduled = true;
director.once(Director.EVENT_AFTER_UPDATE, () => {
this._composeFxFlushScheduled = false;
const grids = this._pendingComposeFxGrids.splice(0);
const heroIds = this._pendingComposeHeroIds.splice(0);
if (grids.length > 0) {
BattleAudio.playCompose();
for (const w1 of heroIds) BattleAudio.playComposeVoice(w1);
}
});
}
/**
* 为本轮备战 type3 扩展格绑定拖拽(地图上不常驻字块节点)。
* 落子后 PREP_TYPE3_MOVE_WINDOW_MS 内可拖;到期后卸热区并不可再拖。
*/
private _bindPrepType3Cells(): void {
const keepIdx = this._prepType3DraggingIndex;
this._clearPrepType3Surfaces(keepIdx);
if (!this._bagSession || !this._map || !this._canDrag()) return;
const now = Date.now();
let soonestRemainMs = Infinity;
const mapBoard = this._bagSession.merge.map;
for (const idx of mapBoard.gridIndices) {
if (idx === keepIdx) continue;
if (!mapBoard.isPrepType3(idx) || mapBoard.hasTile(idx)) continue;
const placedAt = mapBoard.getPrepType3PlacedAtMs(idx);
if (placedAt == null || !canDragPrepType3Cell(true, false, placedAt, now)) continue;
const remain = PREP_TYPE3_MOVE_WINDOW_MS - (now - placedAt);
if (remain < soonestRemainMs) soonestRemainMs = remain;
const mapGrid = this._map.getMapGridByIndex(idx);
if (!mapGrid?.node?.isValid) continue;
const surface = new Node('__prepType3Drag');
surface.parent = mapGrid.node;
surface.setPosition(0, 0, 0);
const ut = surface.addComponent(UITransform);
const parentUt = mapGrid.node.getComponent(UITransform);
if (parentUt) {
ut.setContentSize(parentUt.contentSize);
} else {
ut.setContentSize(75, 75);
}
surface.addComponent(BlockInputEvents);
surface.setSiblingIndex(mapGrid.node.children.length - 1);
surface.on(Node.DragEvent.DRAG_START, () => this._onPrepType3DragStart(idx, surface), this);
surface.on(Node.DragEvent.DRAG_MOVE, (e: EventTouch) => this._onPrepType3DragMove(e, surface), this);
surface.on(Node.DragEvent.DRAG_END, (e: EventTouch) => this._onPrepType3DragEnd(surface, e), this);
surface.draggable = true;
this._prepType3Surfaces.set(idx, surface);
}
if (soonestRemainMs < Infinity) {
this._schedulePrepType3Lock(soonestRemainMs);
}
}
/** 5s 可移动窗口到期:拖动中则延后,松手后再卸热区 */
private _schedulePrepType3Lock(remainMs: number): void {
this._clearPrepType3LockTimer();
this._prepType3LockTimer = setTimeout(() => {
this._prepType3LockTimer = null;
if (this._prepType3DraggingIndex != null) {
this._prepType3LockPending = true;
return;
}
this._bindPrepType3Cells();
}, Math.max(0, remainMs));
}
private _clearPrepType3LockTimer(): void {
if (this._prepType3LockTimer != null) {
clearTimeout(this._prepType3LockTimer);
this._prepType3LockTimer = null;
}
}
/** 拖动结束:销毁临时热区并按最新窗口重绑 */
private _finishPrepType3SurfaceDrag(): void {
const idx = this._prepType3DraggingIndex;
this._prepType3DraggingIndex = null;
this._prepType3LockPending = false;
if (idx != null) {
const surface = this._prepType3Surfaces.get(idx);
if (surface?.isValid) {
surface.removeDragEvent?.();
surface.destroy();
}
this._prepType3Surfaces.delete(idx);
}
this._bindPrepType3Cells();
}
private _onPrepType3DragStart(gridIndex: number, surface: Node): void {
if (this._activeDrag || !this._canDrag() || !this._bagSession || !this._map) {
surface.stopDrag();
return;
}
this._prepType3DraggingIndex = gridIndex;
this._prepType3LockPending = false;
this._clearPrepType3LockTimer();
const result = this._bagSession.pickupFromMap(gridIndex);
if ('error' in result) {
surface.stopDrag();
this._prepType3DraggingIndex = null;
const msg = rejectReasonToMessage(result.error);
if (msg) gg.ui.showToast(msg);
return;
}
const mapGrid = this._map.getMapGridByIndex(gridIndex);
if (!mapGrid?.node?.isValid) {
surface.stopDrag();
this._prepType3DraggingIndex = null;
this._bagSession.revertType3Pickup(gridIndex, result.tile);
return;
}
const ghostEntity = buildTextGridEntityData(
{ kind: 'map', gridIndex },
{ weapon2Id: GRID_WEAPON2_ID, source: result.tile.source },
{ player: this._battle?.player ?? gg.game.localPlayer },
);
const ghost = ghostEntity ? this._spawnTextGrid(ghostEntity, this._dragLayer!) : null;
if (!ghost) {
surface.stopDrag();
this._prepType3DraggingIndex = null;
this._bagSession.revertType3Pickup(gridIndex, result.tile);
return;
}
ghost.enableDrag(false);
ghost.node.worldPosition = mapGrid.node.worldPosition.clone();
this._activeDrag = {
grid: ghost,
source: { kind: 'map', gridIndex },
type3Backup: { gridIndex, tile: result.tile },
};
this._showDragGuide(GRID_WEAPON2_ID);
if (this._battle) {
this._pathFlow?.beginDrag(this._map, this._battle.session, true);
this._battle.beginBagDragSlowMo();
}
// 避免 NodeDragExt 把热区移走挡住下方格子
surface.setPosition(0, 0, 0);
}
private _onPrepType3DragMove(event: EventTouch, surface: Node): void {
surface.setPosition(0, 0, 0);
if (!this._activeDrag || !this._dragLayer?.isValid) return;
const ut = this._dragLayer.getComponent(UITransform);
if (!ut) return;
const ui = event.getUILocation();
const local = ut.convertToNodeSpaceAR(new Vec3(ui.x, ui.y, 0));
this._activeDrag.grid.node.setPosition(local);
const mapIndex = this._map ? this._resolveMapGridAt(ui) : null;
this._updateType3PathPreview(mapIndex, this._activeDrag);
}
private _onPrepType3DragEnd(surface: Node, event: EventTouch): void {
try {
surface.setPosition(0, 0, 0);
const drag = this._activeDrag;
if (!drag) {
surface.stopDrag();
this._clearDragGuide();
return;
}
this._onDragEnd(drag.grid, event);
} finally {
if (this._prepType3DraggingIndex != null) {
this._finishPrepType3SurfaceDrag();
} else if (this._prepType3LockPending) {
this._prepType3LockPending = false;
this._bindPrepType3Cells();
}
}
}
/** 背包字块:入场未落地、待解锁格、空装饰格不可拖 */
private _canEnableBagGridDrag(grid: TextGrid): boolean {
if (!this._canDrag() || BagSlotEntranceAnimator.isPlaying(grid)) return false;
const loc = grid.location;
if (loc?.kind === 'bag' && this._land.isNoDrag(loc.slotIndex)) return false;
return true;
}
/** 入场未落地的字块不显示可合成箭头 */
private _setLandedUpgradeHint(grid: TextGrid | null | undefined): void {
if (!grid?.isValid) return;
if (BagSlotEntranceAnimator.isPlaying(grid)) return;
grid.setCanUpgradeHint(true);
}
private _refreshDragEnabled(): void {
if (!this._bagSession || !this._battle) return;
const enabled = this._canDrag();
for (const g of this._slotGrids.values()) {
if (!g?.isValid) continue;
g.enableDrag(this._canEnableBagGridDrag(g));
this._ensureMapClickHandler(g);
}
for (const g of this._mapGrids.values()) {
if (!g?.isValid) continue;
g.enableDrag(enabled);
this._ensureMapClickHandler(g);
}
for (const s of this._prepType3Surfaces.values()) {
if (s?.isValid) s.draggable = enabled;
}
}
private _canDrag(): boolean {
const battle = this._battle;
if (!battle || battle.session.ended) return false;
const phase = battle.session.phase;
if (phase === BattlePhase.WAIT_REVIVE || phase === BattlePhase.PAUSE) {
return false;
}
if (battle.session.isContinuousFlow) return true;
return phase === BattlePhase.PREP;
}
/** Weapon2.type === Grid(type3 扩展格),仅此类拖动会改怪物路线 */
private _isType3Drag(
grid: TextGrid,
drag?: { type3Backup?: unknown } | null,
): boolean {
if (drag?.type3Backup) return true;
if (grid.weapon2Id === GRID_WEAPON2_ID) return true;
WeaponComposeConfigProvider.instance.load();
const t = WeaponComposeConfigProvider.instance.getWeapon2Type(grid.weapon2Id);
if (isElementGlyphType(t)) return false;
return t === Weapon2TileType.Grid;
}
/** 悬停格变化时才刷新路线预览(模拟 type3 落在该格) */
private _updateType3PathPreview(
mapIndex: number | null,
drag: { source: DragSource; mapDrag?: { fromGridIndex: number; weapon2Id: number } },
): void {
if (!this._pathFlow || !this._map) return;
if (mapIndex == null) {
this._pathFlow.previewType3AtCell(null);
return;
}
const fromMapIndex = drag.mapDrag?.fromGridIndex
?? (drag.source.kind === 'map' ? drag.source.gridIndex : -1);
if (mapIndex === fromMapIndex) {
this._pathFlow.previewType3AtCell(null);
return;
}
const mg = this._map.getMapGridByIndex(mapIndex);
if (!mg?.isCanWalk || !mg.isCanPutGrid) {
this._pathFlow.previewType3AtCell(null);
return;
}
this._pathFlow.previewType3AtCell({ row: mg.Row, col: mg.Col });
}
/** 拉线/落子判定用的字块来源快照(含 TileSource,functiontype=3 未开挖格依赖它) */
private _buildDragTileSourceInfo(
grid: TextGrid,
drag?: NonNullable<typeof this._activeDrag> | null,
): {
weapon2Id: number;
level?: number;
weapon1Id?: number;
source?: TileSource;
fromMapGrid?: number;
battleCardKind?: number;
} {
const fromMapIndex = drag ? this._mapDragFromIndex(drag) : -1;
let tileSource = drag?.mapDrag?.tileSource;
if (tileSource == null && fromMapIndex >= 0) {
tileSource = this._bagSession?.merge.map.getTile(fromMapIndex)?.source;
}
if (tileSource == null && drag?.type3Backup) {
tileSource = drag.type3Backup.tile.source;
}
let battleCardKind: number | undefined;
if (drag?.source.kind === 'bag') {
const slot = this._bagSession?.getBagSlots()[drag.source.slotIndex];
const kind = Math.floor(Number(slot?.battleCardKind) || 0);
if (kind > 0) battleCardKind = kind;
}
return {
weapon2Id: grid.weapon2Id,
level: grid.level,
weapon1Id: grid.weapon1Id > 0 ? grid.weapon1Id : undefined,
source: tileSource,
fromMapGrid: fromMapIndex >= 0 ? fromMapIndex : undefined,
battleCardKind,
};
}
/** 拖拽开始:按 Weapon2 类型与字块来源高亮可落子地图格 */
private _showDragGuide(
weapon2Id: number,
source?: { level?: number; weapon1Id?: number; source?: TileSource; fromMapGrid?: number },
): void {
if (!this._map || !this._bagSession) return;
MapDragGuideHelper.show(this._map, this._bagSession, weapon2Id, source);
}
/** 拖拽结束/打断:清除引导并结束拖动画面弱化 */
private _clearDragGuide(): void {
this._battle?.forceEndBagDragSlowMo();
MapDragGuideHelper.clear(this._map);
this._clearMergeTargetShake();
this._clearDragGhost();
this._setDragSelectTarget(null);
this._setDragPlacementHint('off');
if (this._mapRangeSelectTarget == null && this._bagRangeSelectSlot == null) {
this._rangePresenter.hide();
}
this._linePresenter.hide();
this._pathFlow?.endDrag();
// 松手后恢复「全部可升级」箭头(拖动中只亮过合成目标)
this._refreshCanUpgradeHints();
}
/** 拉线拖动:在 dragLayer 生成半透明字块副本跟手,本体留在槽/格 */
private _ensureDragGhost(from: TextGrid): void {
this._clearDragGhost();
if (!this._dragLayer?.isValid || !from?.node?.isValid) return;
const entity = from.getEntityData();
if (!entity) return;
const host = this._ensureDragGhostHost();
if (!host?.isValid) return;
const node = gg.res.getNode('prefab/', 'textGrid', GBundle.BattleGame);
if (!node?.isValid) {
BagTextDragDebugLog.steal('GHOST_GETNODE_NULL', `fromW2=${from.weapon2Id}`);
return;
}
// 池污染防护:绝不能把正在槽/地图索引中的字块当 ghost 摘走;勿 putNode(会从父节点摘掉)
const stolen = node.getComponent(TextGrid);
if (node === from.node || (stolen && this._isTextGridTracked(stolen))) {
BagTextDragDebugLog.poolHitLive(
'ghostGetNodeTracked',
`fromW2=${from.weapon2Id} stolenW2=${stolen?.weapon2Id ?? -1} sameNode=${node === from.node} path=${BagTextDragDebugLog.nodeParentPath(node)}`,
);
return;
}
const beforeParent = from.node.parent?.name ?? 'null';
node.parent = host;
node.setWorldPosition(from.node.worldPosition);
node.setSiblingIndex(host.children.length - 1);
const comp = node.getComponent(TextGrid) ?? node.addComponent(TextGrid);
if (comp === from) {
BagTextDragDebugLog.poolHitLive('ghostSameComp', `fromW2=${from.weapon2Id}`);
// 已挂到 host:立刻挂回源槽,避免半透明 copy 残留
from.restoreOrigin();
from.ensureBagGlyphVisible();
return;
}
BagTextDragDebugLog.steal(
'GHOST_CREATED',
`fromW2=${from.weapon2Id} fromParentBefore=${beforeParent} ghostPath=${BagTextDragDebugLog.nodeParentPath(node)}`,
);
comp.prepareForPool();
this._writeGridData(comp, entity);
// 纯视觉副本:清掉源字 location,避免被 _isMapHostedTextGrid 当成在场字而拒绝回收
comp.location = null;
// setData 时仍带地图格:已激活组合会按 HeroActor 盖住本格藏掉字皮;
// 清 location 后须按「非在场」重刷,否则拖动残影只剩空框
comp.syncPresentationVisibility();
this._forgetGridBind(comp);
node.name = CocosBagBoardView.DRAG_GHOST_NAME;
comp.enableDrag(false);
comp.setMapClickHandler(null);
comp.setCanUpgradeHint(false);
comp.isSelectStatus = false;
comp.isRedSelectStatus = false;
const uo = node.getComponent(UIOpacity) ?? node.addComponent(UIOpacity);
uo.opacity = CocosBagBoardView.DRAG_GHOST_OPACITY;
for (const block of node.getComponentsInChildren(BlockInputEvents)) {
block.enabled = false;
}
this._dragGhost = comp;
}
/** ghost 创建后:确认源字仍挂在背包槽,被偷走则立刻挂回并补齐 */
private _assertBagSourceStillInSlot(grid: TextGrid, source: DragSource): void {
if (source.kind !== 'bag') return;
const slotNode = this._bagSlotNodeAt(source.slotIndex);
BagTextDragDebugLog.slotChildren(source.slotIndex, slotNode, 'afterGhost');
if (!slotNode?.isValid) return;
if (grid.node?.isValid && grid.node.parent === slotNode) {
gg.res.detachFromPool(grid.node);
BagTextDragDebugLog.steal('SOURCE_STILL_IN_SLOT', `slot=${source.slotIndex} w2=${grid.weapon2Id}`);
return;
}
BagTextDragDebugLog.sourceLeftSlot(source.slotIndex, grid, false);
if (grid.node?.isValid) {
gg.res.detachFromPool(grid.node);
grid.node.parent = slotNode;
grid.node.active = true;
grid.node.setPosition(0, 0, 0);
grid.rememberOrigin();
grid.ensureBagGlyphVisible();
this._grids.setBagGrid(source.slotIndex, grid);
BagTextDragDebugLog.sourceLeftSlot(source.slotIndex, grid, true);
BagTextDragDebugLog.slotChildren(source.slotIndex, slotNode, 'afterRepair');
return;
}
this._slotGrids.delete(source.slotIndex);
this.ensureBagSlotGrid(source.slotIndex);
BagTextDragDebugLog.slotChildren(source.slotIndex, slotNode, 'afterRespawn');
}
/** ghost 挂在独立宿主下,避免被战斗特效弱化逻辑误伤 */
private _ensureDragGhostHost(): Node | null {
if (!this._dragLayer?.isValid) return null;
let host = this._dragLayer.getChildByName('__bagDragGhostHost');
if (!host?.isValid) {
host = new Node('__bagDragGhostHost');
host.parent = this._dragLayer;
host.setPosition(0, 0, 0);
const uo = host.addComponent(UIOpacity);
uo.opacity = 255;
}
host.active = true;
host.setSiblingIndex(this._dragLayer.children.length - 1);
return host;
}
/** 拉线起点:槽/格上的本体 */
private _lineDragAnchorWorld(
grid: TextGrid,
_drag?: NonNullable<typeof this._activeDrag> | null,
): Readonly<Vec3> | null {
return grid.node?.isValid ? grid.node.worldPosition : null;
}
/** 拉线终点:跟手的半透明副本 */
private _lineDragCursorWorld(
_grid: TextGrid,
_drag?: NonNullable<typeof this._activeDrag> | null,
): Readonly<Vec3> | null {
const ghost = this._dragGhost?.node;
return ghost?.isValid ? ghost.worldPosition : null;
}
private _syncDragGhostToTouch(uiPos: Readonly<{ x: number; y: number }>): void {
const ghost = this._dragGhost;
if (!ghost?.node?.isValid) return;
// 挂在 __bagDragGhostHost 下时用世界坐标,避免宿主缺 UITransform 导致跟手错位
ghost.node.setWorldPosition(uiPos.x, uiPos.y, 0);
}
/**
* 拉线松手飞行起点:拖动起始格(拉线锚点),不是松手光标。
* 须从原格弧线跳到目标格。
*/
private _resolveLineDragPlaceStartWorld(
grid: TextGrid,
drag: NonNullable<typeof this._activeDrag>,
_event?: EventTouch,
): Vec3 {
const anchor = this._lineDragAnchorWorld(grid, drag);
if (anchor) return anchor.clone();
if (drag.source.kind === 'bag') {
const slotNode = this._bagSlotNodeAt(drag.source.slotIndex);
if (slotNode?.isValid) return slotNode.worldPosition.clone();
} else {
const mapIndex = drag.mapDrag?.fromGridIndex ?? drag.source.gridIndex;
const mapNode = this._map?.getMapGridByIndex(mapIndex)?.node;
if (mapNode?.isValid) return mapNode.worldPosition.clone();
}
return grid.node.worldPosition.clone();
}
private _clearDragGhost(): void {
const ghost = this._dragGhost;
this._dragGhost = null;
// 只清 _dragGhost 引用不够:拒绝回收 / 创建失败时会在 host 下留下半透明孤儿 copy
this._recycleDragGhostNode(ghost?.node);
const host = this._dragLayer?.isValid
? this._dragLayer.getChildByName('__bagDragGhostHost')
: null;
if (host?.isValid) {
for (const child of [...host.children]) {
this._recycleDragGhostNode(child);
}
}
this._purgeLeftoverDragGhosts();
}
/** 手影被挂到地图格后,host 扫描清不到;不扫背包,避免合成飞行中误回收真身 */
private _purgeLeftoverDragGhosts(): void {
const skip = this._activeDrag?.grid ?? null;
const cells = this._map?.mapGrids;
if (!cells?.length) return;
for (let i = 0; i < cells.length; i++) {
this._purgeDragGhostsOnParent(cells[i]?.node, skip);
}
}
/** 指定父节点下的手影回池 */
private _purgeDragGhostsOnParent(parent: Node | null | undefined, skip: TextGrid | null): void {
if (!parent?.isValid) return;
for (const child of [...parent.children]) {
if (!this._isDragGhostNode(child)) continue;
const tg = child.getComponent(TextGrid);
if (tg === skip) continue;
if (tg?.isValid && this._grids.isIndexed(tg)) continue;
if (tg?.isValid) this._forceRecycleTextGrid(tg);
else if (child['resKey']) gg.res.putNode(child);
else child.destroy();
}
}
/** 回收 ghost 宿主下的 textGrid;仅当前拖动真身挂回原点,宿主下的 copy 一律回池 */
private _recycleDragGhostNode(node: Node | null | undefined): void {
if (!node?.isValid) return;
const grid = node.getComponent(TextGrid);
const host = this._dragLayer?.isValid
? this._dragLayer.getChildByName('__bagDragGhostHost')
: null;
const underGhostHost = !!(host?.isValid && node.parent === host);
if (grid?.isValid) {
if (this._activeDrag?.grid === grid || this._grids.isIndexed(grid)) {
console.error('[CocosBagBoardView] 拒绝回收仍在用的 textGrid ghost', grid.weapon2Id);
const uo = node.getComponent(UIOpacity);
if (uo) uo.opacity = 255;
node.name = 'textGrid';
if (underGhostHost) grid.restoreOrigin();
grid.ensureBagGlyphVisible();
return;
}
// host 下的视觉 copy:即使 setData 带了 map location,也必须回池(曾因此残留半透明叠字)
if (underGhostHost || this._isDragGhostNode(node)) {
BagSlotEntranceAnimator.stopGrid(grid);
grid.prepareForPool();
if (node['resKey']) gg.res.putNode(node);
else node.destroy();
return;
}
if (this._isTextGridTracked(grid)) {
console.error('[CocosBagBoardView] 拒绝回收仍在用的 textGrid ghost', grid.weapon2Id);
const uo = node.getComponent(UIOpacity);
if (uo) uo.opacity = 255;
grid.restoreOrigin();
grid.ensureBagGlyphVisible();
return;
}
BagSlotEntranceAnimator.stopGrid(grid);
grid.prepareForPool();
} else {
Tween.stopAllByTarget(node);
}
// 与 _spawnTextGrid 对称走节点池;destroy 延迟一帧且不回池,易在落点残留半透明叠字
if (node['resKey']) gg.res.putNode(node);
else node.destroy();
}
/** 交换/落子飞行:从槽/地图索引摘下,供 rebuild 回收 */
private _detachGridForFlight(grid: TextGrid, from: GridDetachSource): void {
this._grids.detachForFlight(grid, from);
}
/** 背包字块上场后绑定待生效攻速符与千里眼 */
private _afterBagAttackSpeedPlace(source: DragSource, target: DropTarget): void {
if (source.kind !== 'bag' || target.kind !== 'map') return;
this.tryBindBagAttackSpeedAfterPlace(source.slotIndex, target.gridIndex);
this.tryBindBagAttackRangeAfterPlace(source.slotIndex, target.gridIndex);
}
/** 玩家放置/交换/合成成功短震;自动飞不震 */
private _vibratePlaceSuccess(): void {
if (this._autoMergeAnimating) return;
BattleAudio.vibratePlace();
}
/**
* 地图飞行:显隐该格所属 HeroActor,并同步仍留在格上的字皮。
* 藏英雄时只开字皮,不走 sync(sync 会 ensureVisual 把英雄又打开)。
* @param gridIndex 地图格
* @param visible 是否显示英雄
*/
private _setMapHeroFlightVisible(gridIndex: number, visible: boolean): void {
HeroActorHost.instance.setVisibleAtGrid(gridIndex, visible);
const indices = HeroActorHost.instance.gridIndicesCovering(gridIndex);
for (let i = 0; i < indices.length; i++) {
const grid = this._mapGrids.get(indices[i]);
if (!grid?.isValid) continue;
if (visible) {
grid.syncPresentationVisibility();
grid.ensureBagGlyphVisible();
} else {
grid.preparePlaceFlightVisual();
}
}
}
/** 地图飞行结束:还原被藏的 HeroActor,并按可见性收起/打开格子皮 */
private _restoreHeroActorAfterMapFlight(fromMapIndex: number, toMapIndex: number): void {
this._setMapHeroFlightVisible(fromMapIndex, true);
this._setMapHeroFlightVisible(toMapIndex, true);
}
/** 落子飞行动画通用回调(拖拽/自动合成共用) */
private _createPlaceHooks(opts: {
onSuccessRebuild: import('./BoardPlaceFlightExecutor').BoardPlaceSuccessRebuild;
onComplete?: (ok: boolean) => void;
validateBeforeDrop?: () => PlaceRejectReason | 'rebuild' | null;
beforeAnim?: () => void;
}): BoardPlaceFlightHooks {
return {
beginDropViewSync: () => this._beginDropViewSync(),
cancelDropViewSync: () => this._cancelDropViewSync(),
onInflightStart: () => { this._mapDropAnimInflight++; },
finishPlaceFlightGuard: (gen) => this._finishPlaceFlightGuard(gen),
drop: (source, target) => {
if (!this._bagSession) return PlaceRejectReason.NotPrepPhase;
return this._bagSession.drop(source, target);
},
commitViews: (grid, target, swap) => this._commitViewsAfterSuccessfulDrop(grid, target, swap),
validateBeforeDrop: opts.validateBeforeDrop,
onDropRejected: (reason) => {
const msg = rejectReasonToMessage(reason);
if (msg) gg.ui.showToast(msg);
},
rebuildOnFailure: () => this._rebuildFromSession(),
flushPendingRebuilds: () => this._flushAllPendingRebuilds(),
requestSessionRebuild: () => this._requestSessionRebuild(false),
disposeOrphan: (grid) => this._disposeDragGridIfOrphan(grid),
onSuccessRebuild: opts.onSuccessRebuild,
onComplete: (ok) => {
const seq = this._textDragDebugSeq;
if (seq > 0) {
BagTextDragDebugLog.end(seq, null, ok ? 'flightOk' : 'flightFail');
this._textDragDebugSeq = 0;
}
opts.onComplete?.(ok);
},
afterDropSuccess: (source, target) => {
this._afterBagAttackSpeedPlace(source, target);
this._tryPresentPendingAttrRewrite();
this._vibratePlaceSuccess();
},
beforeAnim: () => {
opts.beforeAnim?.();
const seq = this._textDragDebugSeq;
if (seq > 0) {
BagTextDragDebugLog.snap(seq, 'beforeFlight', this._dragGhost, 'clearGhost');
}
this._battle?.forceEndBagDragSlowMo();
this._clearDragGhost();
},
onLegPrepare: (g) => {
g.preparePlaceFlightVisual();
g.enableDrag(false);
const seq = this._textDragDebugSeq;
if (seq > 0) BagTextDragDebugLog.snap(seq, 'legPrepare', g);
},
purgeVacatedCell: (from) => this._purgeVacatedCell(from),
};
}
/** 起飞后清原格残留 TextGrid(飞走的节点已挂到 dragLayer) */
private _purgeVacatedCell(from: GridDetachSource): void {
const parent = from.kind === 'bag'
? this._bagSlotNodeAt(from.slotIndex)
: this._map?.getMapGridByIndex(from.gridIndex)?.node;
if (!parent?.isValid) return;
for (const child of [...parent.children]) {
const tg = child.getComponent(TextGrid);
if (!tg?.isValid) continue;
this._forceRecycleTextGrid(tg);
}
}
/**
* 拖动中:可与当前字合成/升级的目标抖动,且仅这些目标显示升级标;
* 其它字的升级标先全部关掉。
*/
private _showMergeTargetShake(drag: NonNullable<typeof this._activeDrag>): void {
this._clearMergeTargetShake();
this._clearAllCanUpgradeHints();
if (!this._bagSession) return;
const src = {
weapon2Id: drag.grid.weapon2Id,
weapon1Id: drag.grid.weapon1Id > 0 ? drag.grid.weapon1Id : undefined,
level: drag.grid.level,
};
const fromMap = drag.mapDrag?.fromGridIndex
?? (drag.source.kind === 'map' ? drag.source.gridIndex : undefined);
const fromBag = drag.source.kind === 'bag' ? drag.source.slotIndex : undefined;
const mark = (g: TextGrid | undefined): void => {
if (!g?.isValid || g === drag.grid) return;
if (BagSlotEntranceAnimator.isPlaying(g)) return;
g.setMergeTargetShake(true);
g.setCanUpgradeHint(true);
membershipAdd(this._mergeShakeGrids, g);
};
const slots = this._bagSession.getBagSlots();
for (let i = 0; i < slots.length; i++) {
if (fromBag != null && i === fromBag) continue;
const slot = slots[i];
if (!slot) continue;
if (this._willElementMerge(src, slot)) {
mark(this._slotGrids.get(i));
}
}
this._bagSession.merge.map.forEachTile((gridIndex, tile) => {
if (fromMap != null && gridIndex === fromMap) return;
if (this._willElementMerge(src, tile)
&& (fromMap == null || !this._isMapMergeBlocked(fromMap))
&& !this._isMapMergeBlocked(gridIndex)) {
mark(this._mapGrids.get(gridIndex));
}
});
}
private _clearMergeTargetShake(): void {
for (const g of membershipToArray(this._mergeShakeGrids)) {
g.setMergeTargetShake(false);
}
this._mergeShakeGrids.clear();
}
private _bindMapGridClicks(): void {
this._unbindMapGridClicks();
const grids = this._map?.mapGrids;
if (!Array.isArray(grids)) return;
for (const mg of grids) {
if (!mg?.node?.isValid) continue;
if (!mg.node.getComponent(BlockInputEvents)) {
mg.node.addComponent(BlockInputEvents);
}
mg.node.on(Node.EventType.TOUCH_END, this._onMapGridTouchEnd, this);
this._mapGridClickNodes.push(mg.node);
}
AiBattleBridge.inst.setAiMapTextClickHandler((g) => this._onTextGridClick(g));
}
private _unbindMapGridClicks(): void {
for (const node of this._mapGridClickNodes) {
if (node?.isValid) {
node.off(Node.EventType.TOUCH_END, this._onMapGridTouchEnd, this);
}
}
this._mapGridClickNodes.length = 0;
AiBattleBridge.inst.setAiMapTextClickHandler(null);
}
/** 地图格上的字块:己方 _mapGrids,敌方 AiMapTextHost */
private _resolveMapTextGrid(mapIndex: number): TextGrid | null {
const own = this._mapGrids.get(mapIndex);
if (own?.node?.isValid) return own;
if (!this._isEnemyPlaceCampCell(mapIndex)) return null;
if (!this._enemyGridHasTile(mapIndex)) return null;
return this.getEnemyMapTextGrid(mapIndex);
}
/** 字块是否敌方阵营地图格 */
private _isEnemyTextGrid(grid: TextGrid): boolean {
const loc = grid.location;
return loc?.kind === 'map' && this._isEnemyPlaceCampCell(loc.gridIndex);
}
/** 字块完整快照(敌方格补 ECS runtime) */
private _resolveTextGridData(grid: TextGrid): TextGridData {
const deploySync = this._isEnemyTextGrid(grid)
? AiBattleBridge.inst.aiDeploySync
: this._deploySync;
const data = grid.getData(deploySync);
if (!this._isEnemyTextGrid(grid)) return data;
const loc = grid.location;
if (loc?.kind !== 'map') return data;
const runtime = this._resolveEnemyRuntimeByGridIndex(loc.gridIndex);
return { ...data, runtime: runtime ?? null };
}
/**
* 战斗内武器详情:解析展示用 Weapon.id。
* @param data 字块快照
* @param enemyCamp 是否敌方格
*/
/** 可用于 ui_type 展示的有效 Weapon.id(配表存在且 type 1~4) */
private _tryDisplayWeaponId(weaponId: number): number {
const id = Math.floor(Number(weaponId) || 0);
if (id <= 0) return 0;
const row = gg.equip.getEquip(id);
if (!row) return 0;
const type = Number(row.type) | 0;
return type >= 1 && type <= 4 ? id : 0;
}
private _resolveBattleWeaponId(data: TextGridData, enemyCamp: boolean): number {
const heroId = data.tower?.config?.id ?? data.weapon1Id;
if (!(heroId > 0)) return 0;
const loadout = data.runtime?.loadout;
if (loadout && loadout.weapon1Id === heroId) {
const fromRuntime = this._tryDisplayWeaponId(loadout.weaponId ?? 0);
if (fromRuntime > 0) return fromRuntime;
}
if (enemyCamp) {
const enemyPlayer = this._battle?.getPlayer(this._resolveEnemyPlayerId()) ?? null;
const fallbackId = resolveHeroBattleWeaponId(heroId, enemyPlayer);
return this._tryDisplayWeaponId(fallbackId) || fallbackId;
}
const fromRuntime = this._tryDisplayWeaponId(loadout?.weaponId ?? 0);
if (fromRuntime > 0) return fromRuntime;
const fallbackId = gg.equip.getHeroDisplayWeaponId(heroId);
return this._tryDisplayWeaponId(fallbackId) || fallbackId;
}
/** 敌方地图格 ECS 实时快照 */
private _resolveEnemyRuntimeByGridIndex(gridIndex: number): TowerRuntimeSnapshot | null {
const battle = this._battle;
const map = this._map;
if (!battle || !map) return null;
const enemyPlayer = battle.getPlayer(this._resolveEnemyPlayerId());
if (!enemyPlayer) return null;
const entityId = findEntityIdByGridIndex(gridIndex, battle.session, map, enemyPlayer);
if (entityId == null) return null;
return TowerQueryService.instance.getRuntimeSnapshot(
battle.session,
entityId,
AiBattleBridge.inst.aiDeploySync,
);
}
/** 字块占格合成体(含敌方) */
private _resolveGridComposition(grid: TextGrid, gridIndex: number): ComposedUnit | null {
const fromEntity = this._resolveTextGridData(grid).composition;
if (fromEntity) return fromEntity;
if (this._isEnemyPlaceCampCell(gridIndex)) {
const enemyPlayer = this._battle?.getPlayer(this._resolveEnemyPlayerId());
return enemyPlayer?.compositions.find(c => c.gridIndices.includes(gridIndex)) ?? null;
}
return this._bagSession?.compositions.find(c => c.gridIndices.includes(gridIndex)) ?? null;
}
/** 点击 mapGrid / 背包字块:type1 显示攻击范围,并打开详情弹窗 */
private _handleTextGridInteract(
target: { kind: 'map' | 'bag'; index: number },
grid: TextGrid,
): void {
const dedupKey = `${target.kind}:${target.index}`;
const frame = director.getTotalFrames();
if (frame === this._textInteractDedupFrame && dedupKey === this._textInteractDedupKey) {
return;
}
this._textInteractDedupFrame = frame;
this._textInteractDedupKey = dedupKey;
const data = this._resolveTextGridData(grid);
const isPeachTree = grid.isPeachTreeTile();
// 五行数字与旧 Single=1 编号重叠,数字格子一律 Hero,勿再开 Desc1
const opensDesc1 = isPeachTree;
const hasHeroDesc = !isPeachTree && !!(data.tower && data.weapon1Id > 0);
const canRange = this._canShowType1Range(grid);
if (
this._textSelectTarget?.kind === target.kind
&& this._textSelectTarget.index === target.index
) {
this._clearTextSelect();
return;
}
if (!opensDesc1 && !hasHeroDesc && !canRange) {
this._clearTextSelect();
return;
}
if (opensDesc1 && !data.tile) {
this._clearTextSelect();
return;
}
this._textSelectTarget = target;
if (canRange) {
this._setRangeSelect(target, grid);
} else {
this._clearRangeVisual();
}
const layout = this._resolveWeaponDescPanelLayout(target, grid);
const onClose = () => {
this._textSelectTarget = null;
this._clearRangeVisual();
};
if (opensDesc1) {
const entity = grid.getEntityData() ?? data;
const panelData: BattleWeaponDesc1PanelData = {
entity,
runtime: data.runtime ?? null,
isPeachTree: isPeachTree,
enemyCamp: this._isEnemyTextGrid(grid),
weaponWorldPos: layout
? { x: layout.weaponWorldPos.x, y: layout.weaponWorldPos.y, z: layout.weaponWorldPos.z }
: undefined,
gridStep: layout?.gridStep,
trySwitchAtUiPos: (uiPos) => this._trySwitchWeaponDescAt(uiPos),
};
void gg.ui.openThenClose(UIID.BattleWeaponDesc1, [UIID.BattleWeaponDesc, UIID.BattleWeaponDescHero], {
data: panelData,
onClose,
});
return;
}
if (hasHeroDesc) {
const enemyCamp = this._isEnemyTextGrid(grid);
const watchEntityId = data.runtime?.entityId
?? this._resolveHeroDescWatchEntityId(target, enemyCamp);
const panelData: BattleWeaponDescHeroPanelData = {
entity: data,
runtime: data.runtime ?? null,
enemyCamp,
weaponId: this._resolveBattleWeaponId(data, enemyCamp),
weaponWorldPos: layout
? { x: layout.weaponWorldPos.x, y: layout.weaponWorldPos.y, z: layout.weaponWorldPos.z }
: undefined,
gridStep: layout?.gridStep,
trySwitchAtUiPos: (uiPos) => this._trySwitchWeaponDescAt(uiPos),
watchTarget: { kind: target.kind, index: target.index },
watchEntityId: watchEntityId ?? undefined,
resolveLiveData: () => this._resolveHeroDescLiveDataForSelect(),
};
void gg.ui.openThenClose(UIID.BattleWeaponDescHero, [UIID.BattleWeaponDesc1, UIID.BattleWeaponDesc], {
data: panelData,
onClose,
});
}
}
/** 当前选中格 → 组合武将详情实时数据(随 _textSelectTarget 变化) */
private _resolveHeroDescLiveDataForSelect(): Pick<BattleWeaponDescHeroPanelData, 'entity' | 'runtime' | 'weaponId'> | null {
const target = this._textSelectTarget;
if (!target) return null;
let grid: TextGrid | null = null;
if (target.kind === 'map') {
grid = this._resolveMapTextGrid(target.index);
} else {
grid = this._slotGrids.get(target.index) ?? null;
}
if (!grid?.node?.isValid) return null;
const enemyCamp = target.kind === 'map' && this._isEnemyPlaceCampCell(target.index);
return this._resolveHeroDescLiveData(target, grid, enemyCamp);
}
/**
* 地图格已部署实体的 ECS id(己方/敌方)。
* @param target 地图格
* @param enemyCamp 是否敌方格
*/
private _resolveHeroDescWatchEntityId(
target: { kind: 'map' | 'bag'; index: number },
enemyCamp: boolean,
): number | null {
if (target.kind !== 'map') return null;
const battle = this._battle ?? gg.game.CurentBattle ?? null;
const map = this._map ?? battle?.Map ?? null;
if (!battle || !map) return null;
const player = enemyCamp
? battle.getPlayer(this._resolveEnemyPlayerId())
: gg.game.localPlayer;
if (!player) return null;
return findEntityIdByGridIndex(target.index, battle.session, map, player);
}
/**
* 组合武将详情弹窗:重新读取当前格 entity/runtime(己方/敌方均走 ECS 实时快照)。
* @param target 地图格或背包槽
* @param grid 字块
* @param enemyCamp 是否敌方阵营
*/
private _resolveHeroDescLiveData(
target: { kind: 'map' | 'bag'; index: number },
grid: TextGrid,
enemyCamp: boolean,
): Pick<BattleWeaponDescHeroPanelData, 'entity' | 'runtime' | 'weaponId'> | null {
if (!grid?.node?.isValid) return null;
let data = this._resolveTextGridData(grid);
if (target.kind === 'map') {
const comp = this._resolveGridComposition(grid, target.index);
if (comp) {
data = { ...data, level: comp.level, composition: comp };
}
}
let runtime = data.runtime ?? null;
if (target.kind === 'map') {
if (enemyCamp) {
runtime = this._resolveEnemyRuntimeByGridIndex(target.index) ?? runtime;
} else {
runtime = TowerQueryService.instance.getRuntimeByGridIndex(
target.index,
this._battle?.session ?? null,
this._deploySync,
) ?? runtime;
}
if (!(runtime?.entityId > 0)) {
const entityId = this._resolveHeroDescWatchEntityId(target, enemyCamp);
const session = this._battle?.session ?? gg.game.CurentBattle?.session ?? null;
if (entityId != null && entityId > 0 && session) {
runtime = TowerQueryService.instance.getRuntimeSnapshot(session, entityId) ?? runtime;
}
}
}
return {
entity: data,
runtime,
weaponId: this._resolveBattleWeaponId(data, enemyCamp),
};
}
/** 武器详情弹窗定位:武器世界坐标 + 相邻格实测垂直间距 */
private _resolveWeaponDescPanelLayout(
target: { kind: 'map' | 'bag'; index: number },
grid: TextGrid,
): { weaponWorldPos: Vec3; gridStep: number } | null {
const weaponNode = grid.node;
if (!weaponNode?.isValid) return null;
const weaponWorldPos = weaponNode.worldPosition.clone();
let gridStep = this._map?.mapGridSize ?? 93;
if (target.kind === 'map' && this._map) {
const mg = this._map.getMapGridByIndex(target.index);
const above1 = mg ? this._map.getMapGrid(mg.Row - 1, mg.Col) : null;
if (mg?.node?.isValid && above1?.node?.isValid) {
gridStep = Math.abs(mg.node.worldPosition.y - above1.node.worldPosition.y);
}
}
return { weaponWorldPos, gridStep };
}
/**
* 武器详情遮罩点击:命中可展示详情的字块则切换选中(面板保持打开),返回 true;
* 未命中返回 false,由面板关闭。
*/
private _trySwitchWeaponDescAt(uiPos: { x: number; y: number }): boolean {
const bagSlotIndex = this._resolveBagSlotAt(uiPos);
if (bagSlotIndex != null) {
const grid = this._slotGrids.get(bagSlotIndex);
if (grid?.node?.isValid && this._gridCanShowWeaponDesc(grid)) {
this._handleTextGridInteract({ kind: 'bag', index: bagSlotIndex }, grid);
return true;
}
}
const mapIndex = this._resolveMapGridAt(uiPos);
if (mapIndex != null) {
const grid = this._resolveMapTextGrid(mapIndex);
if (grid?.node?.isValid && this._gridCanShowWeaponDesc(grid)) {
this._handleTextGridInteract({ kind: 'map', index: mapIndex }, grid);
return true;
}
}
return false;
}
private _gridCanShowWeaponDesc(grid: TextGrid): boolean {
const data = this._resolveTextGridData(grid);
if (grid.isPeachTreeTile()) return !!data.tile;
return !!(data.tower && data.weapon1Id > 0);
}
private _onMapGridTouchEnd(event: EventTouch): void {
if (this._activeDrag) return;
const mapIndex = this._resolveMapGridAt(event.getUILocation());
if (mapIndex == null) {
this._clearTextSelect();
return;
}
const textGrid = this._resolveMapTextGrid(mapIndex);
if (!textGrid?.node?.isValid) {
this._clearTextSelect();
return;
}
this._handleTextGridInteract({ kind: 'map', index: mapIndex }, textGrid);
}
/** 字块攻击范围(像素):已部署优先读 runtime */
private _resolveGridRangePx(grid: TextGrid): number {
const data = this._resolveTextGridData(grid);
const px = data.runtime?.stats.range ?? data.tower?.stats.range ?? 0;
return px > 0 ? px : 0;
}
private _onTextGridClick(grid: TextGrid): void {
if (this._activeDrag) return;
const loc = grid.location;
if (!loc) return;
if (loc.kind === 'map') {
this._handleTextGridInteract({ kind: 'map', index: loc.gridIndex }, grid);
} else if (loc.kind === 'bag') {
this._handleTextGridInteract({ kind: 'bag', index: loc.slotIndex }, grid);
}
}
private _setRangeSelect(
target: { kind: 'map' | 'bag'; index: number },
grid: TextGrid,
): void {
this._clearRangeVisual();
if (target.kind === 'map') {
this._mapRangeSelectTarget = target.index;
const mg = this._map?.getMapGridByIndex(target.index);
if (mg?.node?.isValid) {
mg.isSelectStatus = true;
}
const center = this._resolveRangeCenterWorld(target, grid);
if (center) this._updateRangePreview(center, grid);
return;
}
this._bagRangeSelectSlot = target.index;
this._setBagSlotSelect(target.index, true);
const center = this._resolveRangeCenterWorld(target, grid);
if (center) this._updateRangePreview(center, grid);
}
/**
* 攻击范围圆心:多格合成取占格世界坐标中心,否则取当前字块中心。
*/
private _resolveRangeCenterWorld(
target: { kind: 'map' | 'bag'; index: number },
grid: TextGrid,
): Vec3 | null {
if (target.kind === 'map' && this._map) {
const composition = this._resolveGridComposition(grid, target.index);
if (composition && composition.gridIndices.length >= 2) {
let sx = 0;
let sy = 0;
let sz = 0;
let n = 0;
for (const idx of composition.gridIndices) {
const mg = this._map.getMapGridByIndex(idx);
if (!mg?.node?.isValid) continue;
const p = mg.node.worldPosition;
sx += p.x;
sy += p.y;
sz += p.z;
n++;
}
if (n > 0) return new Vec3(sx / n, sy / n, sz / n);
}
const mg = this._map.getMapGridByIndex(target.index);
return mg?.node?.isValid ? mg.node.worldPosition.clone() : null;
}
const slotNode = this._bagSlotNodeAt(target.index);
const centerNode = grid.node?.isValid ? grid.node : slotNode;
return centerNode?.isValid ? centerNode.worldPosition.clone() : null;
}
private _clearRangeVisual(): void {
if (this._mapRangeSelectTarget != null && this._map) {
const mg = this._map.getMapGridByIndex(this._mapRangeSelectTarget);
if (mg?.node?.isValid) {
mg.isSelectStatus = false;
}
}
this._mapRangeSelectTarget = null;
if (this._bagRangeSelectSlot != null) {
this._setBagSlotSelect(this._bagRangeSelectSlot, false);
}
this._bagRangeSelectSlot = null;
this._rangePresenter.hide();
}
private _clearTextSelect(): void {
this._textSelectTarget = null;
this._clearRangeVisual();
gg.ui.closePanel(UIID.BattleWeaponDesc);
gg.ui.closePanel(UIID.BattleWeaponDesc1);
gg.ui.closePanel(UIID.BattleWeaponDescHero);
}
/** 背包拖线:拖拽跟随字块显示 select / redSelect(图3),地图格仅保留引导 addIcon */
private _setDragSelectTarget(
target: { kind: 'map' | 'bag'; index: number; hint?: DragPlacementHint } | null,
dragGrid?: TextGrid | null,
): void {
const prev = this._dragSelectTarget;
if (
prev?.kind === target?.kind
&& prev?.index === target?.index
&& (prev?.kind !== 'map' || prev?.hint === target?.hint)
) {
return;
}
if (prev?.kind === 'bag') {
this._setBagSlotSelect(prev.index, false);
}
this._dragSelectTarget = target;
if (!target) {
this._rangePresenter.hide();
this._setDragPlacementHint('off');
return;
}
if (target.kind === 'bag') {
this._rangePresenter.hide();
this._setDragPlacementHint('valid');
this._setBagSlotSelect(target.index, true);
return;
}
const mg = this._map?.getMapGridByIndex(target.index);
if (mg?.node?.isValid && target.hint === 'valid') {
this._updateRangePreview(mg.node.worldPosition, dragGrid ?? this._activeDrag?.grid ?? null);
} else {
this._rangePresenter.hide();
}
this._setDragPlacementHint(target.hint ?? 'off');
}
/** 拖拽跟随节点(半透明 ghost)上的绿/红框 */
private _setDragPlacementHint(hint: DragPlacementHint): void {
const visual = this._dragGhost;
if (!visual?.node?.isValid) return;
if (hint === 'valid') {
visual.isRedSelectStatus = false;
visual.isSelectStatus = true;
} else if (hint === 'blocked') {
visual.isSelectStatus = false;
visual.isRedSelectStatus = true;
} else {
visual.isSelectStatus = false;
visual.isRedSelectStatus = false;
}
}
/** 可展示攻击范围的字块:ui_range 圆心由调用方传入(合成体为组合中心) */
private _updateRangePreview(centerWorld: Readonly<Vec3>, grid: TextGrid | null): void {
if (!this._canShowType1Range(grid)) {
this._rangePresenter.hide();
return;
}
const rangePx = this._resolveGridRangePx(grid!);
if (!(rangePx > 0)) {
this._rangePresenter.hide();
return;
}
const map = this._map;
const cellA = map?.getMapGridByIndex(0);
const cellB = map?.getMapGridByIndex(1);
const cellSize = map?.mapGridSize ?? 0;
const posA = cellA?.node?.isValid ? cellA.node.worldPosition : null;
const posB = cellB?.node?.isValid ? cellB.node.worldPosition : null;
this._rangePresenter.showAtWorldCenter(centerWorld, rangePx, cellSize, posA, posB, this._mapRoot?.parent ?? null);
}
/** 格子字(Weapon2.id=5)不展示攻击范围 */
private _isCampGridText(data: TextGridData): boolean {
if (data.tileType !== Weapon2TileType.Single) return false;
return Math.floor(Number(data.tile?.id) || 0) === GRID_WEAPON2_ID;
}
/**
* 可显示攻击范围:type1 单字,或已合成出 weapon1 的组合字(悟/空等)。
* 格子字(Weapon2.id=5)除外。
*/
private _canShowType1Range(grid: TextGrid | null): boolean {
if (!grid?.node?.isValid) return false;
const data = this._resolveTextGridData(grid);
if (this._isCampGridText(data)) return false;
if (!data.tower && !data.runtime) return false;
const rangePx = this._resolveGridRangePx(grid);
if (!(rangePx > 0)) return false;
if (data.tileType === Weapon2TileType.Single) return true;
return data.weapon1Id > 0;
}
/** 背包槽 select:有 textGrid 时高亮字块,空槽时高亮槽位上的 select 节点 */
private _setBagSlotSelect(slotIndex: number, selected: boolean): void {
const slotNode = this._bagSlotNodeAt(slotIndex);
const slotSelect = slotNode?.getChildByName('select') ?? null;
const tg = this._slotGrids.get(slotIndex);
if (!selected) {
if (tg?.node?.isValid) {
tg.isSelectStatus = false;
tg.isRedSelectStatus = false;
}
if (slotSelect && isValid(slotSelect)) slotSelect.active = false;
return;
}
if (tg?.node?.isValid) {
tg.isSelectStatus = true;
if (slotSelect && isValid(slotSelect)) slotSelect.active = false;
return;
}
if (slotSelect && isValid(slotSelect)) slotSelect.active = true;
}
private _clearBagGrids(): void {
this._bagEntrancePlaying = false;
BagSlotEntranceAnimator.stopAll(this._slotGrids, this._bagList, false);
for (const g of this._slotGrids.values()) {
this._recycleTextGridNode(g?.node);
}
this._slotGrids.clear();
for (const g of this._grids.takeFlightGrids()) {
this._recycleTextGridNode(g?.node);
}
}
private _countMergeTiles(): number {
if (!this._bagSession) return 0;
let n = 0;
this._bagSession.merge.map.forEachTile(() => { n++; });
return n;
}
private _clearMapGrids(): void {
for (const g of this._mapGrids.values()) {
this._recycleTextGridNode(g?.node);
}
this._mapGrids.clear();
for (const g of this._grids.takeFlightGrids()) {
this._recycleTextGridNode(g?.node);
}
this._clearPrepType3Surfaces();
}
private _clearPrepType3Surfaces(keepGridIndex: number | null = null): void {
this._clearPrepType3LockTimer();
const surfaceIdx = mapKeysToArray(this._prepType3Surfaces);
for (let i = 0; i < surfaceIdx.length; i++) {
const idx = surfaceIdx[i];
if (keepGridIndex != null && idx === keepGridIndex) continue;
const surface = this._prepType3Surfaces.get(idx);
if (surface?.isValid) {
surface.removeDragEvent?.();
surface.destroy();
}
this._prepType3Surfaces.delete(idx);
}
}
/** 自动合成动画进行中 */
private _autoMergeAnimating = false;
get isAutoMergeAnimating(): boolean {
return this._autoMergeAnimating;
}
get isDragActive(): boolean {
return this._activeDrag != null;
}
/**
* 被动 buff 产字:从 UI 世界坐标飞字到未翻开地图格,落地后写入 session。
* @param opts.fromWorld 起点(被动 skill 图标)
* @param opts.gridIndex 目标 Dig 格
* @param opts.weapon2Id Weapon2 文字 id
* @param opts.onComplete 是否成功落子
*/
playPassiveTileSpawnFlight(opts: {
fromWorld: Readonly<Vec3>;
gridIndex: number;
weapon2Id: number;
onComplete: (ok: boolean) => void;
}): void {
const finish = (ok: boolean): void => {
opts.onComplete(ok);
};
const bagSession = this._bagSession;
const dragLayer = this._dragLayer;
const map = this._map;
if (!bagSession || !dragLayer?.isValid || !map) {
finish(false);
return;
}
const gridIndex = Math.floor(Number(opts.gridIndex) || 0);
const weapon2Id = Math.floor(Number(opts.weapon2Id) || 0);
if (gridIndex < 0 || weapon2Id <= 0) {
finish(false);
return;
}
const mapGrid = map.getMapGridByIndex(gridIndex);
if (!mapGrid?.node?.isValid) {
finish(false);
return;
}
const cellKind = bagSession.merge.map.getCellKind(gridIndex);
if (cellKind !== MapCellKind.Dig || bagSession.merge.map.hasTile(gridIndex)) {
finish(false);
return;
}
WeaponComposeConfigProvider.instance.load();
const provider = WeaponComposeConfigProvider.instance;
const w2Type = provider.getWeapon2Type(weapon2Id) as Weapon2TileType;
if (!provider.allowsPassiveDigSpawn(weapon2Id)) {
console.log('[CocosBagBoardView] 被动产字 weapon2 类型非法', weapon2Id, w2Type);
finish(false);
return;
}
const weapon1Id = provider.getWeapon1ByGroupSequence([weapon2Id]) ?? undefined;
const previewTile: MapTileRecord = {
weapon2Id,
source: TileSource.PassiveFunction3,
weapon1Id,
level: 1,
};
const entity = buildTextGridEntityData(
{ kind: 'map', gridIndex },
previewTile,
{ player: this._battle?.player ?? gg.game.localPlayer },
);
if (!entity) {
finish(false);
return;
}
const flightGrid = this._spawnTextGrid(entity, dragLayer, false);
if (!flightGrid?.node?.isValid) {
finish(false);
return;
}
const targetWorld = mapGrid.node.worldPosition.clone();
BagToMapPlaceAnimator.play(flightGrid, targetWorld, dragLayer, () => {
const session = this._bagSession;
if (!session) {
this._disposeDragGridIfOrphan(flightGrid);
finish(false);
return;
}
const liveKind = session.merge.map.getCellKind(gridIndex);
if (liveKind !== MapCellKind.Dig || session.merge.map.hasTile(gridIndex)) {
this._disposeDragGridIfOrphan(flightGrid);
finish(false);
return;
}
const reason = session.grantPassiveTileOnDigGrid(gridIndex, weapon2Id);
if (reason) {
this._disposeDragGridIfOrphan(flightGrid);
finish(false);
return;
}
this._commitMapTextGridView(gridIndex, flightGrid);
mapGrid.playFallEffect?.();
BattleAudio.playPlaceGrid();
this._refreshCanUpgradeHints();
if (this._deploySync && map.gridsInitialized) {
this._deploySync.reconcileFromMerge('passiveTileSpawn');
}
finish(true);
}, {
attachParent: this._mapPlaceAttach(gridIndex).attachParent,
restWorld: this._mapPlaceAttach(gridIndex).restWorld,
startWorld: opts.fromWorld,
showTrail: DeviceProfile.enableBoardPlaceTrail,
});
}
/** 程序化拖放(自动合成):等背包入场结束后,从槽位/地图格当前位置飞向合成目标 */
performAutoDrop(source: DragSource, target: DropTarget, onComplete?: (ok: boolean) => void): void {
if (!this.canAutoMerge) {
onComplete?.(false);
return;
}
const grid = source.kind === 'bag'
? this._slotGrids.get(source.slotIndex) ?? null
: this._mapGrids.get(source.gridIndex) ?? null;
if (!grid?.node?.isValid || !this._dragLayer?.isValid) {
onComplete?.(false);
return;
}
if (!this._prepareAutoDragGrid(source, grid)) {
onComplete?.(false);
return;
}
this._autoMergeAnimating = true;
const finish = (ok: boolean) => {
this._autoMergeAnimating = false;
// drop 在动画未结束前触发 MergeEvent,rebuild 会被 defer;须在此 flush
this._flushAllPendingRebuilds();
this._refreshDragEnabled();
onComplete?.(ok);
};
if (source.kind === 'bag' && target.kind === 'map') {
this._autoDropBagToMap(grid, source.slotIndex, target.gridIndex, finish);
} else if (source.kind === 'bag' && target.kind === 'bag') {
this._autoDropBagToBag(grid, source.slotIndex, target.slotIndex, finish);
} else if (source.kind === 'map' && target.kind === 'map') {
this._autoDropMapToMap(grid, source.gridIndex, target.gridIndex, finish);
} else {
finish(false);
}
}
/** 自动合成前:停止残留 tween,把字块挂回槽位/地图格并归零本地坐标 */
private _prepareAutoDragGrid(source: DragSource, grid: TextGrid): boolean {
if (this._bagEntrancePlaying) return false;
grid.resetNodeTransform();
grid.setDragFollow(true);
if (source.kind === 'bag') {
const slotNode = this._bagSlotNodeAt(source.slotIndex);
if (!slotNode?.isValid) return false;
if (grid.node.parent !== slotNode) {
grid.node.parent = slotNode;
}
grid.node.setPosition(0, 0, 0);
return true;
}
return this._placeMapTextGrid(grid, source.gridIndex);
}
private _autoDropBagToMap(
grid: TextGrid,
slotIndex: number,
mapIndex: number,
finish: (ok: boolean) => void,
): void {
const mapGrid = this._map?.getMapGridByIndex(mapIndex);
if (!mapGrid?.node?.isValid || !this._bagSession) {
finish(false);
return;
}
const slot = this._bagSession.getBagSlots()[slotIndex];
if (slot) {
const reject = this._rejectMapDropUpgrade(slot, mapIndex);
if (reject) {
finish(false);
return;
}
}
const target: DropTarget = { kind: 'map', gridIndex: mapIndex };
const targetWorld = mapGrid.node.worldPosition.clone();
const bagSlotNode = this._bagSlotNodeAt(slotIndex);
const displacedGrid = this._mapGrids.get(mapIndex) ?? null;
const swapToBag = displacedGrid?.node?.isValid
&& bagSlotNode?.isValid
&& this._willBagMapSwap(slotIndex, mapIndex);
if (swapToBag) {
this._detachGridForFlight(displacedGrid!, { kind: 'map', gridIndex: mapIndex });
}
this._detachGridForFlight(grid, { kind: 'bag', slotIndex });
const displacedStartWorld = swapToBag && displacedGrid?.node?.isValid
? displacedGrid.node.worldPosition.clone()
: null;
BoardPlaceFlightExecutor.execute({
dragLayer: this._dragLayer!,
flightGen: this._placeFlightGen,
dropSource: { kind: 'bag', slotIndex },
dropTarget: target,
primaryLeg: {
grid,
targetWorld,
attachParent: swapToBag || !displacedGrid?.node?.isValid ? this._mapPlaceAttach(mapIndex).attachParent : undefined,
restWorld: swapToBag || !displacedGrid?.node?.isValid ? this._mapPlaceAttach(mapIndex).restWorld : undefined,
vacateFrom: { kind: 'bag', slotIndex },
},
swapLeg: swapToBag && bagSlotNode && displacedStartWorld
? {
grid: displacedGrid!,
targetWorld: bagSlotNode.worldPosition,
attachParent: bagSlotNode,
vacateFrom: { kind: 'map', gridIndex: mapIndex },
startWorld: displacedStartWorld,
}
: null,
swapCommit: swapToBag && displacedGrid
? { grid: displacedGrid, target: { kind: 'bag', slotIndex } }
: undefined,
hooks: this._createPlaceHooks({
onSuccessRebuild: 'none',
beforeAnim: () => this._setMapHeroFlightVisible(mapIndex, false),
onComplete: (ok) => {
grid.setDragFollow(true);
this._setMapHeroFlightVisible(mapIndex, true);
if (!ok) this._rebuildFromSession();
finish(ok);
},
}),
});
}
private _autoDropBagToBag(
grid: TextGrid,
fromSlot: number,
toSlot: number,
finish: (ok: boolean) => void,
): void {
const targetBagNode = this._bagSlotNodeAt(toSlot);
const sourceBagNode = this._bagSlotNodeAt(fromSlot);
if (!targetBagNode?.isValid || !sourceBagNode?.isValid || !this._bagSession) {
finish(false);
return;
}
const displacedGrid = this._slotGrids.get(toSlot) ?? null;
const swapToSource = displacedGrid?.node?.isValid
&& this._willBagBagSwap(fromSlot, toSlot);
if (swapToSource) {
this._detachGridForFlight(displacedGrid!, { kind: 'bag', slotIndex: toSlot });
}
this._detachGridForFlight(grid, { kind: 'bag', slotIndex: fromSlot });
const displacedStartWorld = swapToSource && displacedGrid?.node?.isValid
? displacedGrid.node.worldPosition.clone()
: null;
BoardPlaceFlightExecutor.execute({
dragLayer: this._dragLayer!,
flightGen: this._placeFlightGen,
dropSource: { kind: 'bag', slotIndex: fromSlot },
dropTarget: { kind: 'bag', slotIndex: toSlot },
primaryLeg: {
grid,
targetWorld: targetBagNode.worldPosition,
attachParent: swapToSource || !displacedGrid?.node?.isValid ? targetBagNode : undefined,
vacateFrom: { kind: 'bag', slotIndex: fromSlot },
},
swapLeg: swapToSource && displacedStartWorld
? {
grid: displacedGrid!,
targetWorld: sourceBagNode.worldPosition,
attachParent: sourceBagNode,
vacateFrom: { kind: 'bag', slotIndex: toSlot },
startWorld: displacedStartWorld,
}
: null,
swapCommit: swapToSource && displacedGrid
? { grid: displacedGrid, target: { kind: 'bag', slotIndex: fromSlot } }
: undefined,
hooks: this._createPlaceHooks({
onSuccessRebuild: 'none',
onComplete: (ok) => {
grid.setDragFollow(true);
if (!ok) this._rebuildFromSession();
finish(ok);
},
}),
});
}
private _autoDropMapToMap(
grid: TextGrid,
fromMapIndex: number,
toMapIndex: number,
finish: (ok: boolean) => void,
): void {
const mapGrid = this._map?.getMapGridByIndex(toMapIndex);
const sourceMapGrid = this._map?.getMapGridByIndex(fromMapIndex);
if (!mapGrid?.node?.isValid || !sourceMapGrid?.node?.isValid || !this._bagSession) {
finish(false);
return;
}
const srcTile = this._bagSession.merge.map.getTile(fromMapIndex);
if (srcTile) {
const reject = this._rejectMapDropUpgrade(srcTile, toMapIndex, fromMapIndex);
if (reject) {
finish(false);
return;
}
}
const displacedGrid = this._mapGrids.get(toMapIndex) ?? null;
const swapMap = displacedGrid?.node?.isValid
&& this._willMapMapSwap(fromMapIndex, toMapIndex);
if (swapMap) {
this._detachGridForFlight(displacedGrid!, { kind: 'map', gridIndex: toMapIndex });
}
this._detachGridForFlight(grid, { kind: 'map', gridIndex: fromMapIndex });
const displacedStartWorld = swapMap && displacedGrid?.node?.isValid
? displacedGrid.node.worldPosition.clone()
: null;
BoardPlaceFlightExecutor.execute({
dragLayer: this._dragLayer!,
flightGen: this._placeFlightGen,
dropSource: { kind: 'map', gridIndex: fromMapIndex },
dropTarget: { kind: 'map', gridIndex: toMapIndex },
primaryLeg: {
grid,
targetWorld: mapGrid.node.worldPosition,
attachParent: swapMap || !displacedGrid?.node?.isValid ? this._mapPlaceAttach(toMapIndex).attachParent : undefined,
restWorld: swapMap || !displacedGrid?.node?.isValid ? this._mapPlaceAttach(toMapIndex).restWorld : undefined,
vacateFrom: { kind: 'map', gridIndex: fromMapIndex },
},
swapLeg: swapMap && displacedStartWorld
? {
grid: displacedGrid!,
targetWorld: sourceMapGrid.node.worldPosition,
attachParent: this._mapPlaceAttach(fromMapIndex).attachParent,
restWorld: this._mapPlaceAttach(fromMapIndex).restWorld,
vacateFrom: { kind: 'map', gridIndex: toMapIndex },
startWorld: displacedStartWorld,
}
: null,
swapCommit: swapMap && displacedGrid
? { grid: displacedGrid, target: { kind: 'map', gridIndex: fromMapIndex } }
: undefined,
hooks: this._createPlaceHooks({
onSuccessRebuild: 'flush',
beforeAnim: () => {
this._setMapHeroFlightVisible(fromMapIndex, false);
this._setMapHeroFlightVisible(toMapIndex, false);
},
onComplete: (ok) => {
grid.setDragFollow(true);
this._restoreHeroActorAfterMapFlight(fromMapIndex, toMapIndex);
this._repairMapTileViewAfterDrop(fromMapIndex);
this._repairMapTileViewAfterDrop(toMapIndex);
this._refreshDragEnabled();
if (!ok) this._rebuildFromSession();
finish(ok);
},
}),
});
}
private _clearAllGrids(): void {
this._clearBagGrids();
this._clearMapGrids();
}
}
function dragSourceToDefaultTarget(source: DragSource | undefined): DropTarget {
if (!source) return { kind: 'bag', slotIndex: 0 };
if (source.kind === 'bag') return { kind: 'bag', slotIndex: source.slotIndex };
return { kind: 'map', gridIndex: source.gridIndex };
}
function isDropCancelled(source: DragSource, target: DropTarget): boolean {
if (source.kind === 'bag' && target.kind === 'bag') {
return source.slotIndex === target.slotIndex;
}
if (source.kind === 'map' && target.kind === 'map') {
return source.gridIndex === target.gridIndex;
}
return false;
}