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.
863 lines
32 KiB
863 lines
32 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;
|
|
/** 地图字 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<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 (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;
|
|
}
|
|
}
|
|
|