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, isBagToolTextType, 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 { 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; /** 拖字删除落点(BattleGame ui_delete) */ private _deleteNode: 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 { return this._grids.slotGrids; } private get _mapGrids(): Map { return this._grids.mapGrids; } /** 备战 type3 扩展格(无字块节点)的拖拽热区 */ private _prepType3Surfaces = new Map(); /** type3 限时拖动到期后重新绑定热区 */ private _prepType3LockTimer: ReturnType | 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 | 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(); 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 | 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): 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, prevMap?: ReadonlyMap, ): 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); // 绑键未变也会跳过 setData;putNode 可能关掉数字 Spine,须始终修字形 grid.ensureBagGlyphVisible(); 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); onCell.ensureBagGlyphVisible(); 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): 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, ): 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 | 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 | 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)) { live.ensureBagGlyphVisible(); 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(); private _composeKeysSeeded = false; /** 待播合成音效的地图格(等 tile 事件 rebuild 后再播) */ private _pendingComposeFxGrids: number[] = []; private _pendingComposeHeroIds: number[] = []; private _composeFxFlushScheduled = false; /** 拖动中正在抖动的可合成目标 */ private _mergeShakeGrids = createMembershipSet(); /** 主动 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 | 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 | 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 | 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 | null { return this._inkstoneWorldPosProvider?.() ?? null; } /** 声明下一次背包刷新入场由本视图在 BagRefreshed 时一次性播放 */ armBagEntrance(fromWorld?: Readonly | 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; } /** * 绑定拖字删除节点(BattleGame ui_delete)。 * @param node 删除落点;无效则不能拖字删除 */ setDeleteNode(node: Node | null | undefined): void { this._deleteNode = 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 留空 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.setDeleteNode(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 | null): void { this._rebuildBagFromSession(playEntrance, fromWorld); } /** * 取当前地图上所有字块视图(gridIndex → TextGrid)。 * 战斗表现层(CocosBattleTowerPresenter)通过它按格子定位塔的皮肤节点播放动画。 * 备战/战斗期间随字块增删实时维护,调用方无需缓存。 */ getMapGrids(): Map { return this._mapGrids; } /** * 取当前背包槽字块视图(slotIndex → TextGrid)。 */ getBagGrids(): Map { 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 merge = this._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 merge = this._bagSession.merge; this._suppressDefaultUpgradeFx = true; try { 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); const merge = this._bagSession.merge; this._suppressDefaultUpgradeFx = true; try { if (target.kind === 'map') { const rolled = merge.rollBattleBuffRandomLevelMap(target.gridIndex, levelUpProbsWan, rollWan); if (typeof rolled !== 'number') return null; const result = merge.applyBattleBuffRandomLevelMapDelta(target.gridIndex, rolled); return typeof result === 'number' ? result : null; } const rolled = merge.rollBattleBuffRandomLevelBag(target.slotIndex, levelUpProbsWan, rollWan); if (typeof rolled !== 'number') return null; const result = merge.applyBattleBuffRandomLevelBagDelta(target.slotIndex, rolled); return typeof result === 'number' ? result : null; } 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(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): void { if (!this._bagSession || !this._map || weapon1Ids.size === 0) return; const played = createMembershipSet(); 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 | null): void { this._fillMissingBagSlotGrids(); const flying = new Map(); 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 | 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); if (this._isOverDelete(uiPos)) { this._setDragSelectTarget(null); const del = this._deleteNode; if (lineFrom && del?.isValid) { this._linePresenter.show(lineFrom, del.worldPosition); } else { this._linePresenter.hide(); } if (drag && this._isType3Drag(grid, drag)) { this._updateType3PathPreview(null, drag); } return; } 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 (this._isOverDelete(event.getUILocation())) { this._discardGlyphToDelete(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 = this._bagSession?.formatRejectMessage(reason) ?? 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._tryPresentPendingAttrRewrite(); 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, ): 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 (this._isOverDelete(uiPos)) { this._discardGlyphToDelete(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, 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): 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, 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, 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); if (isBagToolTextType(srcType)) return false; 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; } /** * 背包或地图上的字拖到 ui_delete:飞到该节点后消失,不留在格子上。 * @param grid 被拖的字 * @param drag 本次拖拽 */ private _discardGlyphToDelete(grid: TextGrid, drag: NonNullable): void { const session = this._bagSession; const del = this._deleteNode; const layer = this._dragLayer; if (!del?.isValid || !del.activeInHierarchy) { console.log('[CocosBagBoardView] ui_delete 不可用,取消删除'); this._lineDragCancel(grid, drag); return; } if (!session || !layer?.isValid || !grid.node?.isValid) { console.log('[CocosBagBoardView] 删除字块缺少拖拽层或会话'); this._lineDragCancel(grid, drag); 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 = del.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): 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); } /** * 飞到 ui_delete 后回收字块,并补上推迟的盘面刷新。 * @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(); } /** * 手指是否在 ui_delete 上。 * @param uiPos 屏幕坐标 */ private _isOverDelete(uiPos: { x: number; y: number }): boolean { const node = this._deleteNode; if (!node?.isValid || !node.activeInHierarchy) return false; return this._hitNode(node, uiPos); } /** 空装饰格节点。当前没有这种格子 */ 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): 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; } const prevMapLv = Math.floor(Number(this._grids.getMapGrid(event.gridIndex)?.level) || 0); const mapLevelDown = event.level < prevMapLv; 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 && !mapLevelDown) { this._playMapUpgradeEffect(event.gridIndex); } this._refreshCanUpgradeHints(); this._refreshSkillBindings(); break; } this._requestSessionRebuild(false); if (!this._suppressDefaultUpgradeFx && !mapLevelDown) { 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; } // 属性卡换五行会换 Hero.id:必须先 DeploySync 再刷视图/Skill, // 否则 ensureNumberSkill 绑到旧实体,reconcile 销毁后数字停攻。 if (this._deploySync && this._map.gridsInitialized) { this._deploySync.reconcileFromMerge('glyphRewritten'); } if (this._applyMapGlyphRewriteInPlace(event.target.gridIndex)) { this._refreshCanUpgradeHints(); this._refreshSkillBindings(); } else { this._requestSessionRebuild(false); this._scheduleComposeFxFlush(); } 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 | 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 | 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 | 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(); 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, playBagEntrance: boolean, reservedGrids: MembershipSet, flightGrids: readonly TextGrid[], prevMap?: ReadonlyMap, ): 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 | 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(); 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, weapon2Id: number, preferGridIndex: number, used: MembershipSet, 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 | 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, 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, 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(); 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 | 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 | null, ): Readonly | null { return grid.node?.isValid ? grid.node.worldPosition : null; } /** 拉线终点:跟手的半透明副本 */ private _lineDragCursorWorld( _grid: TextGrid, _drag?: NonNullable | null, ): Readonly | 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, _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 = this._bagSession?.formatRejectMessage(reason) ?? 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): 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 | 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 | 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, 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; 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; }