import { Director, director, Tween, tween, v3, Vec3, type Node } from 'cc'; import { canDragMapTile, Weapon2TileType, isElementGlyphType, type ComposedUnit, type DragSource, type DropTarget, type MapTileRecord } from 'db://assets/mx/module/merge'; import { aiLog, aiWarn } from './AiLog'; import type { BattleBagSession } from '../../bag/BattleBagSession'; import type { MapManager } from '../../map/legacy/MapManager'; import type { Player } from '../../../game/Player'; import { GBundle } from '../../../game/ConfigRes'; import { buildTextGridEntityData } from '../../combat/TowerQueryService'; import { TextGrid } from '../../view/TextGrid'; import { BagToMapPlaceAnimator } from '../../view/BagToMapPlaceAnimator'; import { DeviceProfile } from '../../view/DeviceProfile'; import { HeroActorHost } from '../../heros/HeroActorHost'; import { WeaponComposeConfigProvider } from '../../bag/WeaponComposeConfigProvider'; import { AiBoardProbe } from '../decision/AiBoardProbe'; /** AI 地图格 TextGrid 记录 */ interface AiMapTextRec { /** 地图格索引 */ gridIndex: number; /** 字块视图 */ grid: TextGrid; } /** 一次跳飞批次(落地后由 Controller 再 dropLogical) */ interface AiPlaceFlightRun { /** 世代 */ gen: number; /** 未完成飞腿 */ pending: number; /** 主飞字块 */ primary: TextGrid; /** 交换飞字块 */ swap: TextGrid | null; /** 主飞落地后回收 */ recyclePrimary: boolean; /** 交换飞落地后回收 */ recycleSwap: boolean; /** 主飞落点地图格;入背包为 -1 */ primaryMapIndex: number; /** 交换飞落点地图格;入背包为 -1 */ swapMapIndex: number; /** 隐藏英雄:源格 */ hideFrom: number; /** 隐藏英雄:目标格 */ hideTo: number; /** 落地回调 */ onComplete: (ok: boolean) => void; } /** * AI 无头 bag 地图字表现:复用框架 TextGrid(不拖拽)。 * 轻量模式:等级标 + 散字;HeroActor 仍由 DeploySync 管,避免双管与 Box2D 崩。 */ export class AiMapTextHost { private readonly _recs: AiMapTextRec[] = []; private readonly _probe = new AiBoardProbe(); private _pending = false; private _bag: BattleBagSession | null = null; private _map: MapManager | null = null; private _player: Player | null = null; /** 敌方地图字块点击(查看详情 / 攻击范围) */ private _mapClickHandler: ((grid: TextGrid) => void) | null = null; /** 与玩家相同的跳飞层(ui_fly) */ private _flyLayer: Node | null = null; /** 地图字专用层(ui_textGrids) */ private _textGridLayer: Node | null = null; /** 当前跳飞;未飞为 null */ private _flight: AiPlaceFlightRun | null = null; /** 跳飞世代 */ private _flightGen = 0; /** 地图字 flush 后回调(刷新 AI Skill 绑定) */ private _afterFlush: (() => void) | null = null; /** * 地图字对账完成后回调(AFTER_UPDATE flush)。 * @param handler 回调;null 清除 */ setAfterFlush(handler: (() => void) | null): void { this._afterFlush = handler; } /** * 设置敌方地图字块点击回调(对新旧字块均生效)。 * @param handler 点击回调;null 清除 */ setMapClickHandler(handler: ((grid: TextGrid) => void) | null): void { this._mapClickHandler = handler; for (let i = 0; i < this._recs.length; i++) { this._recs[i].grid?.setMapClickHandler(handler); } } /** * 绑定跳飞层(与玩家 ui_fly 同一节点)。 * @param layer 飞行层 */ setFlyLayer(layer: Node | null): void { this._flyLayer = layer; } /** * 绑定地图字专用层。 * @param layer ui_textGrids */ setTextGridLayer(layer: Node | null): void { this._textGridLayer = layer?.isValid ? layer : null; } /** * 尝试播与玩家相同的跳飞;成功则落地后回调,期间不改 merge。 * 背包无 UI:上阵起点取地图最上方一排中间的上方。 * @param source 源 * @param target 目标 * @param onComplete 落地回调 * @returns 是否已开始动画 */ tryPlayDropFlight(source: DragSource, target: DropTarget, onComplete: (ok: boolean) => void): boolean { if (this._flight) { aiWarn('[AiMapTextHost] 跳飞未结束又请求,忽略'); return false; } const bag = this._bag; const map = this._map; const flyLayer = this._flyLayer; if (!bag || !map) return false; if (!flyLayer?.isValid) { aiWarn('[AiMapTextHost] 跳飞层缺失,上阵/移动无跳飞'); return false; } if (source.kind === 'bag' && target.kind === 'bag') return false; const bagOrigin = this._resolveBagOriginWorld(); if (!bagOrigin) { aiWarn('[AiMapTextHost] 无法解析背包上阵起点'); return false; } const startWorld = this._resolveStartWorld(source, bagOrigin); const targetWorld = this._resolveTargetWorld(target, bagOrigin); if (!startWorld || !targetWorld) return false; const swap = this._isSwapDrop(source, target); const destHasTile = target.kind === 'map' && !!bag.merge.map.getTile(target.gridIndex); const recyclePrimary = target.kind === 'bag' || (destHasTile && !swap); const primaryAttach = !recyclePrimary && target.kind === 'map' ? this._placeParent(target.gridIndex) : null; const swapAttach = swap && source.kind === 'map' ? this._placeParent(source.gridIndex) : null; const primary = this._takeFlightGrid(source, startWorld); if (!primary?.node?.isValid) { aiWarn(`[AiMapTextHost] 跳飞主字块缺失 ${source.kind}`); return false; } let swapGrid: TextGrid | null = null; if (swap) { swapGrid = this._takeSwapGrid(target); if (!swapGrid?.node?.isValid) { aiWarn('[AiMapTextHost] 跳飞交换字块缺失,回落瞬移'); this._recycleOrphanFlightGrid(primary); return false; } } const hideFrom = source.kind === 'map' ? source.gridIndex : -1; const hideTo = target.kind === 'map' ? target.gridIndex : -1; this._setHeroFlightVisible(hideFrom, false); this._setHeroFlightVisible(hideTo, false); primary.preparePlaceFlightVisual(); swapGrid?.preparePlaceFlightVisual(); const gen = ++this._flightGen; this._flight = { gen, pending: swapGrid ? 2 : 1, primary, swap: swapGrid, recyclePrimary, recycleSwap: !!swapGrid && !swapAttach, primaryMapIndex: recyclePrimary || target.kind !== 'map' ? -1 : target.gridIndex, swapMapIndex: swapAttach && source.kind === 'map' ? source.gridIndex : -1, hideFrom, hideTo, onComplete, }; BagToMapPlaceAnimator.play(primary, targetWorld, flyLayer, this._onFlightLegDone, { attachParent: primaryAttach, restWorld: primaryAttach && target.kind === 'map' ? targetWorld : null, startWorld, showTrail: DeviceProfile.enableBoardPlaceTrail, }); if (swapGrid) { const swapDest = swapAttach ? this._cellWorld(source.kind === 'map' ? source.gridIndex : -1) ?? bagOrigin : bagOrigin; const swapStart = swapGrid.node.worldPosition.clone(); BagToMapPlaceAnimator.play(swapGrid, swapDest, flyLayer, this._onFlightLegDone, { attachParent: swapAttach, restWorld: swapAttach && source.kind === 'map' ? swapDest : null, startWorld: swapStart, showTrail: DeviceProfile.enableBoardPlaceTrail, }); } return true; } /** * 请求对账(延后到本帧物理之后,避免与 Box2D 同帧改节点)。 * @param bag AI 背包会话 * @param map 地图 * @param player AI Player */ requestSync(bag: BattleBagSession, map: MapManager, player: Player | null): void { this._bag = bag; this._map = map; this._player = player; if (this._flight) return; if (this._pending) return; this._pending = true; director.once(Director.EVENT_AFTER_UPDATE, this._onAfterUpdate); } /** 立即对账(开战首帧 / 测试) */ syncNow(bag: BattleBagSession, map: MapManager, player: Player | null): void { this._bag = bag; this._map = map; this._player = player; this._flush(); } /** * 妙手神偷:从托管中摘除字块视图(不销毁),供飞行层演出。 * @param gridIndex 地图格索引 */ detachGridForSteal(gridIndex: number): TextGrid | null { for (let i = 0; i < this._recs.length; i++) { if (this._recs[i].gridIndex !== gridIndex) continue; const grid = this._recs[i].grid; this._recs.splice(i, 1); if (grid?.node?.isValid) { Tween.stopAllByTarget(grid.node); return grid; } return null; } return null; } /** * AI 妙手神偷落地:把飞行中的字块挂到 AI 地图格并纳入托管。 * @param gridIndex 落点格 * @param grid 飞行字块 */ attachStolenGrid(gridIndex: number, grid: TextGrid): void { if (!grid?.node?.isValid) return; const bag = this._bag; const map = this._map; if (!bag || !map) return; const tile = bag.merge.map.getTile(gridIndex); if (!tile) return; for (let i = this._recs.length - 1; i >= 0; i--) { if (this._recs[i].gridIndex !== gridIndex && this._recs[i].grid !== grid) continue; this._recs.splice(i, 1); } this._purgeTextGridAtIndex(gridIndex, grid); Tween.stopAllByTarget(grid.node); if (!this._placeAtIndex(grid, gridIndex)) return; grid.node.setScale(1, 1, 1); grid.node.active = true; const composition = this._compositionAt(bag, gridIndex); const entity = buildTextGridEntityData( { kind: 'map', gridIndex }, { weapon2Id: tile.weapon2Id, weapon1Id: tile.weapon1Id, level: tile.level ?? composition?.level ?? 1, source: tile.source, }, { composition, player: this._player }, ); if (!entity) return; this._applyLite(grid, entity); this._recs.push({ gridIndex, grid }); this._playDropIn(grid.node); } /** 清空全部 AI 地图字块 */ clear(): void { director.off(Director.EVENT_AFTER_UPDATE, this._onAfterUpdate); this._pending = false; this._mapClickHandler = null; this.abortFlight(); this._flyLayer = null; this._textGridLayer = null; this._bag = null; this._map = null; this._player = null; for (let i = this._recs.length - 1; i >= 0; i--) { this._destroyAt(i); } } /** * 中止跳飞(离场 / 停止 AI)。不触发落地回调。 */ abortFlight(): void { const run = this._flight; this._flight = null; if (run) { this._flightGen++; this._restoreHeroFlight(run); BagToMapPlaceAnimator.stop(run.primary); if (run.swap) BagToMapPlaceAnimator.stop(run.swap); this._recycleOrphanFlightGrid(run.primary); if (run.swap) this._recycleOrphanFlightGrid(run.swap); } } private readonly _onAfterUpdate = (): void => { this._pending = false; if (this._flight) return; this._flush(); }; private readonly _onFlightLegDone = (): void => { const run = this._flight; if (!run) return; run.pending--; if (run.pending > 0) return; this._settleFlight(run); }; /** * 跳飞落地:挂接或回收字块,再通知 Controller dropLogical。 * @param run 本批次 */ private _settleFlight(run: AiPlaceFlightRun): void { if (this._flight !== run) return; this._flight = null; this._restoreHeroFlight(run); if (run.recyclePrimary) this._recycleOrphanFlightGrid(run.primary); else if (run.primaryMapIndex >= 0) this._registerFlightGrid(run.primary, run.primaryMapIndex); if (run.swap) { if (run.recycleSwap) this._recycleOrphanFlightGrid(run.swap); else if (run.swapMapIndex >= 0) this._registerFlightGrid(run.swap, run.swapMapIndex); } if (run.primaryMapIndex >= 0) { this._map?.getMapGridByIndex(run.primaryMapIndex)?.playFallEffect?.(); } run.onComplete(true); } /** * 落地后纳入托管,避免随后 requestSync 再造一块。 * @param grid 字块 * @param gridIndex 地图格 */ private _registerFlightGrid(grid: TextGrid, gridIndex: number): void { this._removeRecByGrid(grid); this._removeRecByIndex(gridIndex); const parent = this._placeParent(gridIndex); if (parent?.isValid && grid.node?.isValid) { this._purgeTextGridAtIndex(gridIndex, grid); this._placeAtIndex(grid, gridIndex); grid.node.setScale(1, 1, 1); } grid.finishPlaceFlightVisual(); this._recs.push({ gridIndex, grid }); } /** * 取出将要起飞的字块(地图摘下 / 背包生成)。 * @param source 源 * @param startWorld 起点世界坐标 */ private _takeFlightGrid(source: DragSource, startWorld: Readonly): TextGrid | null { if (source.kind === 'map') { const existing = this.detachGridForSteal(source.gridIndex); if (existing?.node?.isValid) return existing; return this._spawnFlightFromMap(source.gridIndex, startWorld); } return this._spawnFlightFromBag(source.slotIndex, startWorld); } /** * 取出交换对向字块(目标格/槽上的字)。 * @param target 目标 */ private _takeSwapGrid(target: DropTarget): TextGrid | null { if (target.kind === 'map') { const existing = this.detachGridForSteal(target.gridIndex); if (existing?.node?.isValid) return existing; const wp = this._map?.getMapGridByIndex(target.gridIndex)?.node?.worldPosition; if (!wp) return null; return this._spawnFlightFromMap(target.gridIndex, wp); } const origin = this._resolveBagOriginWorld(); if (!origin) return null; return this._spawnFlightFromBag(target.slotIndex, origin); } /** * 按地图格数据在飞行层生成字块。 * @param gridIndex 格 * @param startWorld 起点 */ private _spawnFlightFromMap(gridIndex: number, startWorld: Readonly): TextGrid | null { const bag = this._bag; const tile = bag?.merge.map.getTile(gridIndex); if (!bag || !tile) return null; const composition = this._compositionAt(bag, gridIndex); const entity = buildTextGridEntityData( { kind: 'map', gridIndex }, { weapon2Id: tile.weapon2Id, weapon1Id: tile.weapon1Id, level: tile.level ?? composition?.level ?? 1, source: tile.source, }, { composition, player: this._player }, ); if (!entity) return null; return this._spawnAtWorld(entity, startWorld); } /** * 按背包槽数据在飞行层生成字块。 * @param slotIndex 槽 * @param startWorld 起点 */ private _spawnFlightFromBag(slotIndex: number, startWorld: Readonly): TextGrid | null { const slot = this._bag?.getBagSlots()[slotIndex]; if (!slot) return null; const entity = buildTextGridEntityData( { kind: 'bag', slotIndex }, slot, { player: this._player }, ); if (!entity) return null; return this._spawnAtWorld(entity, startWorld); } /** * 在飞行层生成字块并放到世界坐标。 * @param entity 快照 * @param startWorld 起点 */ private _spawnAtWorld(entity: NonNullable>, startWorld: Readonly): TextGrid | null { const flyLayer = this._flyLayer; if (!flyLayer?.isValid) return null; const node = gg.res.getNode('prefab/', 'textGrid', GBundle.BattleGame); if (!node?.isValid) { aiLog('[AiMapTextHost] textGrid prefab missing'); return null; } node.active = true; node.parent = flyLayer; node.setWorldPosition(startWorld.x, startWorld.y, startWorld.z); node.setScale(1, 1, 1); const comp = node.getComponent(TextGrid) ?? node.addComponent(TextGrid); this._applyLite(comp, entity); comp.rememberOrigin(); return comp; } /** * 地图最上方一排中间格子的正上方(AI 无背包 UI 时的上阵起点)。 */ private _resolveBagOriginWorld(): Vec3 | null { const map = this._map; const grids = map?.mapGrids; if (!map || !Array.isArray(grids) || grids.length <= 0) return null; let topY = -Infinity; for (let i = 0; i < grids.length; i++) { const n = grids[i]?.node; if (!n?.isValid) continue; if (n.worldPosition.y > topY) topY = n.worldPosition.y; } if (topY === -Infinity) return null; let minX = Infinity; let maxX = -Infinity; for (let i = 0; i < grids.length; i++) { const n = grids[i]?.node; if (!n?.isValid) continue; if (Math.abs(n.worldPosition.y - topY) > 1) continue; if (n.worldPosition.x < minX) minX = n.worldPosition.x; if (n.worldPosition.x > maxX) maxX = n.worldPosition.x; } const midX = (minX + maxX) * 0.5; let best: Node | null = null; let bestDist = Infinity; for (let i = 0; i < grids.length; i++) { const n = grids[i]?.node; if (!n?.isValid) continue; if (Math.abs(n.worldPosition.y - topY) > 1) continue; const d = Math.abs(n.worldPosition.x - midX); if (d < bestDist) { bestDist = d; best = n; } } if (!best) return null; const wp = best.worldPosition.clone(); wp.y += map.mapGridSize; return wp; } /** * 主飞起点:背包用上阵原点,地图用源格。 * @param source 源 * @param bagOrigin 上阵原点 */ private _resolveStartWorld(source: DragSource, bagOrigin: Vec3): Vec3 | null { if (source.kind === 'bag') return bagOrigin.clone(); const n = this._map?.getMapGridByIndex(source.gridIndex)?.node; return n?.isValid ? n.worldPosition.clone() : null; } /** * 主飞终点:背包用上阵原点,地图用目标格。 * @param target 目标 * @param bagOrigin 上阵原点 */ private _resolveTargetWorld(target: DropTarget, bagOrigin: Vec3): Vec3 | null { if (target.kind === 'bag') return bagOrigin.clone(); const n = this._map?.getMapGridByIndex(target.gridIndex)?.node; return n?.isValid ? n.worldPosition.clone() : null; } /** * 落点已占且不是升级/合成时视为交换。 * @param source 源 * @param target 目标 */ private _isSwapDrop(source: DragSource, target: DropTarget): boolean { const bag = this._bag; if (!bag) return false; if (target.kind === 'bag') { return source.kind === 'map' && !!bag.getBagSlots()[target.slotIndex]; } const destTile = bag.merge.map.getTile(target.gridIndex); if (!destTile) return false; const srcTile = source.kind === 'map' ? bag.merge.map.getTile(source.gridIndex) : bag.getBagSlots()[source.slotIndex]; if (!srcTile) return false; const fromMap = source.kind === 'map' ? source.gridIndex : undefined; if (this._probe.canUpgradeDropOnGrid(bag, target.gridIndex, srcTile, fromMap)) return false; WeaponComposeConfigProvider.instance.load(); const provider = WeaponComposeConfigProvider.instance; const srcType = provider.getWeapon2Type(srcTile.weapon2Id) as Weapon2TileType; const dstType = provider.getWeapon2Type(destTile.weapon2Id) as Weapon2TileType; const swappable = isElementGlyphType(srcType); const destOk = isElementGlyphType(dstType); if (!swappable || !destOk) return false; return canDragMapTile(destTile, dstType, false); } /** * 飞行中隐藏/恢复该格 HeroActor。 * @param gridIndex 格;<0 忽略 * @param visible 是否显示 */ private _setHeroFlightVisible(gridIndex: number, visible: boolean): void { if (gridIndex < 0) return; HeroActorHost.instance.setVisibleAtGrid(gridIndex, visible); } /** * 恢复本批次隐藏的英雄皮。 * @param run 批次 */ private _restoreHeroFlight(run: AiPlaceFlightRun): void { this._setHeroFlightVisible(run.hideFrom, true); this._setHeroFlightVisible(run.hideTo, true); } /** * 回收未纳入托管的飞行字块。 * @param grid 字块 */ private _recycleOrphanFlightGrid(grid: TextGrid | null): void { if (!grid?.node?.isValid) return; for (let i = 0; i < this._recs.length; i++) { if (this._recs[i].grid === grid) return; } Tween.stopAllByTarget(grid.node); BagToMapPlaceAnimator.stop(grid); grid.prepareForPool(); if (grid.node['resKey']) gg.res.putNode(grid.node); else grid.node.destroy(); } /** * 按字块引用摘掉托管记录。 * @param grid 字块 */ private _removeRecByGrid(grid: TextGrid): void { for (let i = this._recs.length - 1; i >= 0; i--) { if (this._recs[i].grid !== grid) continue; this._recs.splice(i, 1); } } /** * 按地图格摘掉托管记录(不销毁节点)。 * @param gridIndex 格 */ private _removeRecByIndex(gridIndex: number): void { for (let i = this._recs.length - 1; i >= 0; i--) { if (this._recs[i].gridIndex !== gridIndex) continue; this._recs.splice(i, 1); } } private _flush(): void { const bag = this._bag; const map = this._map; if (!bag || !map) return; const player = this._player; const keep: number[] = []; bag.merge.map.forEachTile((gridIndex) => { const tile = bag.merge.map.getTile(gridIndex); if (!tile) return; const composition = this._compositionAt(bag, gridIndex); const cell = map.getMapGridByIndex(gridIndex)?.node; if (!cell?.isValid) return; keep.push(gridIndex); this._ensure(gridIndex, tile, composition, player); }); for (let i = this._recs.length - 1; i >= 0; i--) { if (keep.indexOf(this._recs[i].gridIndex) >= 0) continue; this._destroyAt(i); } this._afterFlush?.(); } private _compositionAt(bag: BattleBagSession, gridIndex: number): ComposedUnit | null { const list = bag.merge.compositions; for (let i = 0; i < list.length; i++) { if (list[i].gridIndices.indexOf(gridIndex) >= 0) return list[i]; } return null; } private _ensure( gridIndex: number, tile: MapTileRecord, composition: ComposedUnit | null, player: Player | null, ): void { const entity = buildTextGridEntityData( { kind: 'map', gridIndex }, { weapon2Id: tile.weapon2Id, weapon1Id: tile.weapon1Id, level: tile.level ?? composition?.level ?? 1, source: tile.source, }, { composition, player }, ); if (!entity) { aiLog('[AiMapTextHost] buildTileEntity failed', gridIndex, tile.weapon2Id); return; } for (let i = 0; i < this._recs.length; i++) { if (this._recs[i].gridIndex !== gridIndex) continue; const grid = this._recs[i].grid; if (grid?.isValid && grid.node?.isValid) { this._purgeTextGridAtIndex(gridIndex, grid); this._placeAtIndex(grid, gridIndex); if (this._liteSnapshotUnchanged(grid, entity, gridIndex)) { grid.setRuinStyle(!!tile.ruined); return; } Tween.stopAllByTarget(grid.node); this._placeAtIndex(grid, gridIndex); grid.node.setScale(1, 1, 1); grid.node.active = true; this._applyLite(grid, entity); return; } this._recs.splice(i, 1); break; } this._purgeTextGridAtIndex(gridIndex, null); const spawned = this._spawn(entity, gridIndex); if (!spawned) return; this._recs.push({ gridIndex, grid: spawned }); this._playDropIn(spawned.node); } /** * 清掉该格在专用层/格子上的其它 TextGrid。 * @param gridIndex 格 * @param keep 保留的字块;null 表示全清 */ private _purgeTextGridAtIndex(gridIndex: number, keep: TextGrid | null): void { this._purgeHostTextGrids(this._placeParent(gridIndex), gridIndex, keep); this._purgeHostTextGrids(this._map?.getMapGridByIndex(gridIndex)?.node ?? null, gridIndex, keep); } /** * @param host 扫描节点 * @param gridIndex 格 * @param keep 保留 */ private _purgeHostTextGrids(host: Node | null, gridIndex: number, keep: TextGrid | null): void { if (!host?.isValid) return; const children = host.children.slice(); for (let i = 0; i < children.length; i++) { const child = children[i]; if (!child?.isValid) continue; const tg = child.getComponent(TextGrid); if (!tg?.isValid) continue; if (keep && tg === keep) continue; const loc = tg.location; if (host === this._textGridLayer && loc?.kind === 'map' && loc.gridIndex !== gridIndex) continue; for (let r = this._recs.length - 1; r >= 0; r--) { if (this._recs[r].grid === tg) this._recs.splice(r, 1); } Tween.stopAllByTarget(child); tg.prepareForPool(); if (child['resKey']) gg.res.putNode(child); else child.destroy(); } } /** * 地图字父节点:专用层优先。 * @param gridIndex 格 */ private _placeParent(gridIndex: number): Node | null { const layer = this._textGridLayer; if (layer?.isValid) return layer; return this._map?.getMapGridByIndex(gridIndex)?.node ?? null; } /** * 格子世界坐标。 * @param gridIndex 格;<0 无效 */ private _cellWorld(gridIndex: number): Vec3 | null { if (gridIndex < 0) return null; const cell = this._map?.getMapGridByIndex(gridIndex)?.node; if (!cell?.isValid) return null; return cell.worldPosition.clone(); } /** * 挂到专用层并对齐格子。 * @param grid 字块 * @param gridIndex 格 */ private _placeAtIndex(grid: TextGrid, gridIndex: number): boolean { const parent = this._placeParent(gridIndex); if (!parent?.isValid || !grid.node?.isValid) return false; if (grid.node.parent !== parent) grid.node.parent = parent; const world = this._cellWorld(gridIndex); if (world) grid.node.setWorldPosition(world); return true; } /** * @param entity 快照 * @param gridIndex 格 */ private _spawn(entity: NonNullable>, gridIndex: number): TextGrid | null { const node = gg.res.getNode('prefab/', 'textGrid', GBundle.BattleGame); if (!node?.isValid) { aiLog('[AiMapTextHost] textGrid prefab missing'); return null; } node.active = true; node.setScale(1, 1, 1); const comp = node.getComponent(TextGrid) ?? node.addComponent(TextGrid); this._applyLite(comp, entity); this._placeAtIndex(comp, gridIndex); comp.rememberOrigin(); return comp; } /** * 单字落下:轻微缩放弹出(勿改根节点 Y,避免被对账打断后停在偏上)。 * @param node TextGrid 根节点 */ private _playDropIn(node: Node): void { if (!node?.isValid) return; Tween.stopAllByTarget(node); node.setScale(0.88, 0.88, 1); tween(node) .to(0.12, { scale: v3(1.1, 1.1, 1) }, { easing: 'quadOut' }) .to(0.08, { scale: v3(0.98, 0.98, 1) }) .to(0.06, { scale: v3(1, 1, 1) }) .start(); } /** * 字块数据未变:跳过 setData,避免把正在播的「攻击」/收招窗刷回「待机」。 * @param grid 已有字块 * @param entity 新快照 * @param gridIndex 地图格 */ private _liteSnapshotUnchanged(grid: TextGrid, entity: NonNullable>, gridIndex: number): boolean { const loc = grid.location; if (loc?.kind !== 'map' || loc.gridIndex !== gridIndex) return false; if (grid.weapon2Id !== Number(entity.tile.id)) return false; if (grid.weapon1Id !== Number(entity.weapon1Id)) return false; if (grid.level !== entity.level) return false; return true; } /** * 轻量刷新:等级皮 + 点击;损毁倒下跟 AI 字块记录。 * @param grid 字块 * @param entity 快照 */ private _applyLite(grid: TextGrid, entity: NonNullable>): void { grid.setAiMapLite(true); grid.setData(entity); grid.enableDrag(false); grid.setMapClickHandler(this._mapClickHandler); grid.syncPresentationVisibility(); const loc = entity.location; const ruined = loc.kind === 'map' && !!this._bag?.merge.map.getTile(loc.gridIndex)?.ruined; grid.setRuinStyle(ruined); } private _destroyAt(index: number): void { const rec = this._recs[index]; if (!rec) return; const node = rec.grid?.node; if (node?.isValid) { Tween.stopAllByTarget(node); rec.grid.prepareForPool(); if (node['resKey']) gg.res.putNode(node); else node.destroy(); } this._recs.splice(index, 1); } /** * 遍历托管中的地图字块。 * @param visitor 回调 */ forEachGrid(visitor: (grid: TextGrid, gridIndex: number) => void): void { for (let i = 0; i < this._recs.length; i++) { const rec = this._recs[i]; if (rec.grid?.isValid) visitor(rec.grid, rec.gridIndex); } } /** SkillController 用的格 → TextGrid */ getMapGrids(): ReadonlyMap { const out = new Map(); for (let i = 0; i < this._recs.length; i++) { const rec = this._recs[i]; if (rec.grid?.isValid) out.set(rec.gridIndex, rec.grid); } return out; } }