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