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

1006 lines
37 KiB

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;
/** 铲开挖中的字块 */
private _shovelDigGrid: TextGrid | null = null;
/** 铲开挖格 */
private _shovelDigIndex = -1;
/** 铲开挖完成回调 */
private _shovelDigOnDone: ((ok: boolean) => void) | null = null;
/** 铲 Spine 已结束 */
private _shovelDigSpineDone = false;
/** 铲 fill 已结束 */
private _shovelDigFillDone = false;
/** 地图字 flush 后回调(刷新 AI Skill 绑定) */
private _afterFlush: (() => void) | null = null;
/** 铲子开挖收尾(宝箱 / 三选一) */
private _onShovelDigCommitted: ((gridIndex: number) => void) | null = null;
private _afterShovelDig: ((gridIndex: number) => void) | null = null;
/**
* 地图字对账完成后回调(AFTER_UPDATE flush)。
* @param handler 回调;null 清除
*/
setAfterFlush(handler: (() => void) | null): void {
this._afterFlush = handler;
}
/**
* 铲子开挖收尾回调。
* @param handler 被铲格 Index;null 清除
*/
/**
* 铲子逻辑开挖成功(立刻八邻揭示)。
* @param handler 被铲格 Index;null 清除
*/
setOnShovelDigCommitted(handler: ((gridIndex: number) => void) | null): void {
this._onShovelDigCommitted = handler;
}
setAfterShovelDig(handler: ((gridIndex: number) => void) | null): void {
this._afterShovelDig = 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._onShovelDigCommitted = null;
this._afterShovelDig = 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);
}
this.abortShovelDig();
}
/** 铲 Spine 播完 */
private readonly _onShovelDigSpineFinished = (): void => {
this._shovelDigSpineDone = true;
this._tryFinishShovelDig();
};
/** 土地块 fill 播完 */
private readonly _onShovelDigFillFinished = (): void => {
this._shovelDigFillDone = true;
this._tryFinishShovelDig();
};
/** Spine+fill 均结束 → 冒烟并回收 */
private _tryFinishShovelDig(): void {
if (!this._shovelDigSpineDone || !this._shovelDigFillDone) return;
const idx = this._shovelDigIndex;
const mapGrid = idx >= 0 ? this._map?.getMapGridByIndex(idx) : null;
if (mapGrid?.node?.isValid) {
mapGrid.playOpenSpineSmoke();
}
this._finishShovelDig(true);
}
/**
* 铲开挖收尾:回收字块、修复格子皮。
* @param ok 是否成功
*/
private _finishShovelDig(ok: boolean): void {
const grid = this._shovelDigGrid;
const idx = this._shovelDigIndex;
const onDone = this._shovelDigOnDone;
this._shovelDigGrid = null;
this._shovelDigIndex = -1;
this._shovelDigOnDone = null;
this._shovelDigSpineDone = false;
this._shovelDigFillDone = false;
if (grid) {
this._removeRecByGrid(grid);
this._recycleOrphanFlightGrid(grid);
}
if (idx >= 0) {
this._map?.getMapGridByIndex(idx)?.repairShovelDigVisual();
}
if (ok && idx >= 0) {
this._afterShovelDig?.(idx);
}
onDone?.(ok);
}
/**
* 铲飞入目标格后播与玩家相同的开挖链(fill + 铲 Spine → 冒烟)。
* @param gridIndex 开挖格
* @param onComplete 完成回调
* @returns 是否已开始开挖演出
*/
playShovelDig(gridIndex: number, onComplete: (ok: boolean) => void): boolean {
if (this._shovelDigGrid) {
aiWarn('[AiMapTextHost] 铲开挖未结束又请求');
return false;
}
const mapGrid = this._map?.getMapGridByIndex(gridIndex);
let grid: TextGrid | null = null;
for (let i = 0; i < this._recs.length; i++) {
if (this._recs[i].gridIndex !== gridIndex) continue;
grid = this._recs[i].grid;
break;
}
if (!grid?.node?.isValid || !mapGrid?.node?.isValid) {
aiWarn('[AiMapTextHost] 铲开挖缺少字块或格子', gridIndex);
return false;
}
this._placeAtIndex(grid, gridIndex);
const textLayer = this._textGridLayer;
if (textLayer?.isValid && grid.node.parent === textLayer) {
grid.node.setSiblingIndex(textLayer.children.length - 1);
}
grid.preparePlaceFlightVisual();
this._shovelDigGrid = grid;
this._shovelDigIndex = gridIndex;
this._shovelDigOnDone = onComplete;
this._shovelDigSpineDone = false;
this._shovelDigFillDone = false;
this._onShovelDigCommitted?.(gridIndex);
const battleScale = Math.max(0.01, gg.game?.CurentBattle?.TimeScale ?? 1);
const fillSec = grid.resolveShovelDigDurationSec() / battleScale;
mapGrid.playShovelDigFillReveal(fillSec, this._onShovelDigFillFinished);
grid.playShovelDigAction(this._onShovelDigSpineFinished);
return true;
}
/**
* 中止铲开挖演出并回收字块。不触发完成回调。
*/
abortShovelDig(): void {
const grid = this._shovelDigGrid;
const idx = this._shovelDigIndex;
this._shovelDigGrid = null;
this._shovelDigIndex = -1;
this._shovelDigOnDone = null;
this._shovelDigSpineDone = false;
this._shovelDigFillDone = false;
if (grid) {
this._removeRecByGrid(grid);
this._recycleOrphanFlightGrid(grid);
}
if (idx >= 0) {
this._map?.getMapGridByIndex(idx)?.repairShovelDigVisual();
}
}
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 && !run.primary.isShovelTile()) {
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<Vec3>): 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<Vec3>): 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<Vec3>): 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<ReturnType<typeof buildTextGridEntityData>>, startWorld: Readonly<Vec3>): 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 (this._recs[i].grid === this._shovelDigGrid) continue;
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<ReturnType<typeof buildTextGridEntityData>>, 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);
// 先挂进激活节点树再切皮。未入场时 Skeleton 未 __preload,setSkin 会被随后的 defaultSkin「土」盖掉。
this._placeAtIndex(comp, gridIndex);
this._applyLite(comp, entity);
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<ReturnType<typeof buildTextGridEntityData>>, 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<ReturnType<typeof buildTextGridEntityData>>): 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<number, TextGrid> {
const out = new Map<number, TextGrid>();
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;
}
}