import { Director, director } from 'cc'; import { ClientErrorReporter } from '../../../mx/tools/ClientErrorReporter'; import { createMembershipSet, membershipAdd, membershipDelete, type MembershipSet } from '../../../mx/tools/MembershipSet'; import { MergeEventType } from 'db://assets/mx/module/merge/events/MergeEventType'; import type { MergeEvent, MergeEventSubscription, MergeEventListener } from 'db://assets/mx/module/merge'; import type { ComposedUnit } from 'db://assets/mx/module/merge'; import type { EntityId } from 'db://assets/mx/module/ecs'; import type { BattleRuntime } from './BattleRuntime'; import type { MapManager } from '../map/legacy/MapManager'; import { getTowerLoadoutService } from '../combat/TowerLoadoutService'; import { refreshWangMuFieldAtkSteal } from '../skill/WangMuConfig'; import { setupTowerCombat } from '../combat/TowerCombatSetup'; import { getBattleBuffDigitStatHost } from '../combat/BattleBuffDigitStatHost'; import { HeroActorHost } from '../heros/HeroActorHost'; import { isHeroActorWeapon1 } from '../heros/HeroActorRes'; import { mapGridToLogicCell } from '../map/BattleMapSpec'; import { findCompositionForEntity, findEntityIdByGridIndex, type DeployedTowerInfo } from './DeployEntityQuery'; import { TeamComp, TransformComp, GridOccupancyComp } from 'db://assets/mx/module/battle'; import type { Player } from '../../game/Player'; import type { BattleSession } from 'db://assets/mx/module/battle'; import type { TowerLoadoutService } from '../combat/TowerLoadoutService'; export { composeKey } from './ComposeUnitKey'; /** 未部署时先逐帧重试的次数,之后改为间隔慢重试 */ const UNDEPLOY_FAST_RETRY = 5; /** 慢重试间隔(帧) */ const UNDEPLOY_SLOW_GAP = 30; /** * 合成态 → ECS 部署 reconciler。 * 唯一期望态:MergeSession.compositions;ECS 以 PlayerSlot.deployedUnits + GridMap 为实际态。 * 不维护平行注册表 / 快照缓存。 */ export class DeploySync { private _runtime: BattleRuntime | null = null; private _map: MapManager | null = null; private _bagSession: import('../bag/BattleBagSession').BattleBagSession | null = null; private _playerId = 0; private _mergeListener: ((e: MergeEvent) => void) | null = null; /** false:不订阅 ComposeChanged(AI 由 tick 延后 reconcile,避免与物理同帧叠 Actor) */ private _autoReconcileOnCompose = true; /** 未部署合成体的重试次数 */ private _undeployRetry = 0; /** 已排队下一帧重试 */ private _undeployRetryQueued = false; /** 慢重试剩余等待帧 */ private _undeployWait = 0; /** 已打过慢重试日志 */ private _undeploySlowLogged = false; /** * 绑定运行时与背包。 * @param runtime 战斗运行时 * @param map 地图 * @param bagSession 指定 bag;缺省用 runtime.bagSession * @param playerId 部署归属玩家 * @param autoReconcileOnCompose 是否在 ComposeChanged 时即时对账;AI 传 false */ bind(runtime: BattleRuntime, map?: MapManager, bagSession?: import('../bag/BattleBagSession').BattleBagSession | null, playerId = 0, autoReconcileOnCompose = true): void { this.unbind(); this._runtime = runtime; this._map = map ?? runtime.Map ?? null; this._playerId = Math.max(0, Math.floor(Number(playerId) || 0)); this._bagSession = bagSession ?? runtime.bagSession; this._autoReconcileOnCompose = !!autoReconcileOnCompose; const bag = this._bagSession; if (!bag) { return; } if (!this._map) { return; } if (this._autoReconcileOnCompose) { this._mergeListener = () => { this.reconcileFromMerge('ComposeChanged'); }; bag.on(MergeEventType.ComposeChanged as unknown as MergeEventSubscription, this._mergeListener as MergeEventListener); } this.reconcileFromMerge('bind'); } unbind(): void { const bag = this._bagSession; if (bag && this._mergeListener) { bag.off(MergeEventType.ComposeChanged as unknown as MergeEventSubscription, this._mergeListener as MergeEventListener); } this._mergeListener = null; this._runtime = null; this._map = null; this._bagSession = null; this._playerId = 0; this._autoReconcileOnCompose = true; this._undeployRetry = 0; this._undeployRetryQueued = false; this._undeployWait = 0; this._undeploySlowLogged = false; director.off(Director.EVENT_AFTER_UPDATE, this._onUndeployRetry); } /** @deprecated 使用 reconcileFromMerge */ sync(_compositions?: readonly ComposedUnit[], caller = 'unknown'): void { this.reconcileFromMerge(caller); } /** 以 MergeSession.compositions 为唯一期望态,对账 ECS 部署 */ reconcileFromMerge(caller = 'unknown'): void { try { this._reconcileFromMergeImpl(caller); } catch (e) { ClientErrorReporter.report('DeploySync_reconcile', `${caller}|${String(e)}`); this._queueUndeployRetry(); } } /** 对账实现 */ private _reconcileFromMergeImpl(caller: string): void { if (!this._runtime || !this._map) { return; } if (!this._map.gridsInitialized) { return; } const bag = this._bagSession; const compositions = bag?.merge.compositions ?? []; const session = this._runtime.session; const player = this._runtime.getPlayer(this._playerId); const keep = createMembershipSet(); const loadoutSvc = getTowerLoadoutService(); const pid = this._playerId; // 按 Weapon1 收集已部署实体,移动时复用(禁止卖了再建导致 CD/大招被清) const poolByWeapon = new Map(); if (player) { for (const eid of player.deployedUnits) { if (!session.world.isAlive(eid)) continue; const w1 = loadoutSvc.getLoadout(eid)?.weapon1Id; if (w1 == null) continue; let list = poolByWeapon.get(w1); if (!list) { list = []; poolByWeapon.set(w1, list); } list.push(eid); } } // 第一遍:本格已有同 weapon1 实体优先绑定(升级/多同名塔不会互相抢走) const pending: ComposedUnit[] = []; for (let c = 0; c < compositions.length; c++) { const comp = compositions[c]; const cell = this._cellOf(comp); if (!cell) continue; this._markComposeCellBuildable(comp, cell); const onGrid = this._findEntityOnCompGrids(comp, player, session, loadoutSvc); if (onGrid == null) { pending.push(comp); continue; } let taken = this._takeDeployFromPool(poolByWeapon, comp.weapon1Id, onGrid); if (taken == null) taken = onGrid; if (!this._isAliveDeployEntity(taken, comp.weapon1Id, session, loadoutSvc)) { pending.push(comp); continue; } this._bindDeployEntity(comp, cell, taken, keep, caller, pid, player, session, loadoutSvc); } // 第二遍:剩余合成体从同 weapon1 池复用,再不行新建 for (let i = 0; i < pending.length; i++) { const comp = pending[i]; const cell = this._cellOf(comp); if (!cell) continue; let atCell = session.grid.getCell(cell)?.occupant ?? null; if (atCell != null && !session.world.isAlive(atCell)) { session.grid.clearOccupant(atCell); atCell = null; } const prefer = (atCell != null && loadoutSvc.getLoadout(atCell)?.weapon1Id === comp.weapon1Id) ? atCell : null; let entityId = this._takeDeployFromPool(poolByWeapon, comp.weapon1Id, prefer); if (entityId == null && prefer != null) entityId = prefer; if (entityId != null && this._isAliveDeployEntity(entityId, comp.weapon1Id, session, loadoutSvc)) { this._bindDeployEntity(comp, cell, entityId, keep, caller, pid, player, session, loadoutSvc); continue; } if (this._bindOccupyingSameHero(comp, cell, keep, caller, pid, player, session, loadoutSvc)) { continue; } this._evictBlockingOccupantsOnComposition(comp, keep, null); this._markComposeCellBuildable(comp, cell); const unitId = gg.battle.config.getUnitIdByWeapon1(comp.weapon1Id); const compCells = this._compositionLogicCells(comp); const preCheck = unitId ? this._canPlaceCompositionAfterClear(pid, unitId, cell, compCells) : { ok: false as const, reason: 'unknown_unit' as const }; if (preCheck.ok === false) { if (!this._undeploySlowLogged) { console.error( '[DeploySync] 合成体无法部署', caller, 'weapon1=', comp.weapon1Id, 'reason=', preCheck.reason, 'grids=', comp.gridIndices.join(','), ); } continue; } const ok = this._runtime.deploy({ playerId: pid, weapon1Id: comp.weapon1Id, row: cell.row, col: cell.col, level: comp.level, }); if (ok && this._runtime.lastDeployedEntity != null) { const eid = this._runtime.lastDeployedEntity; membershipAdd(keep, eid); this._syncCompositionOccupancy(eid, cell, comp); this._syncHeroActor(eid, comp); } else if (!this._undeploySlowLogged) { console.error('[DeploySync] deploy 失败', caller, 'weapon1=', comp.weapon1Id); } } if (player) { for (const entityId of Array.from(player.deployedUnits)) { if (keep.has(entityId)) continue; if (!session.world.isAlive(entityId)) continue; const sold = session.deploy.sell(pid, entityId); if (sold) { loadoutSvc.removeEntity(entityId); HeroActorHost.instance.destroy(entityId); } } refreshWangMuFieldAtkSteal(session); } this._finishUndeployRetry(compositions, player, session, loadoutSvc); } /** * 部署前清掉合成体占地格上的阻挡实体(含 keep 里 weapon1 不符的误绑塔)。 * @param comp 合成体 * @param keep 本轮已绑定实体 * @param exceptEntity 保留占位的实体(搬家/sync 时自身) */ private _evictBlockingOccupantsOnComposition( comp: ComposedUnit, keep: MembershipSet, exceptEntity: EntityId | null, ): void { const session = this._runtime?.session; if (!session) return; const loadoutSvc = getTowerLoadoutService(); const cells = this._compositionLogicCells(comp); for (let i = 0; i < cells.length; i++) { const cell = cells[i]; let occ = session.grid.getCell(cell)?.occupant ?? null; if (occ == null || occ === exceptEntity) continue; if (!session.world.isAlive(occ)) { session.grid.clearOccupant(occ); continue; } const w1 = loadoutSvc.getLoadout(occ)?.weapon1Id; if (keep.has(occ)) { if (w1 === comp.weapon1Id) continue; membershipDelete(keep, occ); } this._reconcileUntrackedOccupant(cell); occ = session.grid.getCell(cell)?.occupant ?? null; if (occ != null && occ !== exceptEntity && session.world.isAlive(occ)) { session.grid.clearOccupant(occ); } } } /** 合成体锚点格是否可放置(清格后校验 deploy shape) */ private _canPlaceCompositionAfterClear( playerId: number, unitId: string, anchor: { row: number; col: number }, compCells: { row: number; col: number }[], ): { ok: true; cells: { row: number; col: number }[] } | { ok: false; reason: string } { const session = this._runtime?.session; if (!session) return { ok: false, reason: 'unknown_unit' }; if (compCells.length > 0 && !session.grid.isEmptyForDeploy(compCells)) { return { ok: false, reason: 'cell_occupied' }; } const check = session.deploy.canPlace(playerId, unitId, anchor); if (!check.ok) return { ok: false, reason: check.reason ?? 'can_place' }; return { ok: true, cells: compCells.length > 0 ? compCells : (check.cells ?? [anchor]) }; } /** 实体是否存活且 weapon1 与合成体一致 */ private _isAliveDeployEntity( entityId: EntityId, weapon1Id: number, session: BattleSession, loadoutSvc: TowerLoadoutService, ): boolean { if (!session.world.isAlive(entityId)) return false; return loadoutSvc.getLoadout(entityId)?.weapon1Id === weapon1Id; } /** 合成体部署占格(新产品按锚点单格部署) */ private _compositionLogicCells(comp: ComposedUnit): { row: number; col: number }[] { const anchor = this._cellOf(comp); return anchor ? [anchor] : []; } /** * 合成体仍无塔实体时继续对账。快重试之后改为慢重试,避免占格晚放开后永久不攻击。 * @param compositions 当前合成态 * @param player 玩家槽 * @param session 战斗会话 * @param loadoutSvc 配装 */ private _finishUndeployRetry( compositions: readonly ComposedUnit[], player: Player | null | undefined, session: BattleSession, loadoutSvc: TowerLoadoutService, ): void { let missing = false; for (let i = 0; i < compositions.length; i++) { const comp = compositions[i]; if (this._cellOf(comp) == null) continue; if (this._findEntityOnCompGrids(comp, player, session, loadoutSvc) != null) continue; missing = true; break; } if (!missing) { this._undeployRetry = 0; this._undeployWait = 0; this._undeploySlowLogged = false; return; } this._queueUndeployRetry(); } /** 排队重试对账;超过快重试次数后隔帧再试,不放弃 */ private _queueUndeployRetry(): void { if (this._undeployRetryQueued) return; this._undeployRetry++; if (this._undeployRetry > UNDEPLOY_FAST_RETRY) { this._undeployWait = UNDEPLOY_SLOW_GAP; if (!this._undeploySlowLogged) { this._undeploySlowLogged = true; console.error('[DeploySync] 合成体未部署,改为慢重试'); } } this._undeployRetryQueued = true; director.once(Director.EVENT_AFTER_UPDATE, this._onUndeployRetry); } /** 下一帧重试对账;慢重试先空等剩余帧 */ private _onUndeployRetry = (): void => { this._undeployRetryQueued = false; if (!this._runtime) return; if (this._undeployWait > 0) { this._undeployWait--; this._undeployRetryQueued = true; director.once(Director.EVENT_AFTER_UPDATE, this._onUndeployRetry); return; } this.reconcileFromMerge('retry'); }; /** * 脚印上已有同名塔、且不属于另一座存活组合时搬家复用。 * 不拆本轮已绑到别的组合上的塔,避免两座英雄来回抢实体。 * @param comp 合成体 * @param cell 锚点逻辑格 * @param keep 本轮保留集 * @param caller 调用来源 * @param pid 玩家 * @param player 玩家槽 * @param session 会话 * @param loadoutSvc 配装 * @returns 是否已复用 */ private _bindOccupyingSameHero(comp: ComposedUnit, cell: { row: number; col: number }, keep: MembershipSet, caller: string, pid: number, player: Player | null | undefined, session: BattleSession, loadoutSvc: TowerLoadoutService): boolean { const cells = this._compositionLogicCells(comp); for (let i = 0; i < cells.length; i++) { const occ = session.grid.getCell(cells[i])?.occupant ?? null; if (occ == null || !this._isAliveDeployEntity(occ, comp.weapon1Id, session, loadoutSvc)) continue; if (player && this._map) { const owner = findCompositionForEntity(occ, player, session, this._map); if (owner && !this._sameComposeGrids(owner, comp)) continue; } this._bindDeployEntity(comp, cell, occ, keep, caller, pid, player, session, loadoutSvc); return true; } return false; } /** * 两套合成体是否同一组地图格。 * @param a 合成体 * @param b 合成体 */ private _sameComposeGrids(a: ComposedUnit, b: ComposedUnit): boolean { if (a.gridIndices.length !== b.gridIndices.length) return false; for (let i = 0; i < a.gridIndices.length; i++) { if (b.gridIndices.indexOf(a.gridIndices[i]) < 0) return false; } return true; } /** * 从同 weapon1 池取出实体;prefer 命中则优先。 * @param poolByWeapon 池 * @param weapon1Id Hero id * @param prefer 优先实体 */ private _takeDeployFromPool(poolByWeapon: Map, weapon1Id: number, prefer: EntityId | null): EntityId | null { const list = poolByWeapon.get(weapon1Id); if (!list?.length) return null; if (prefer != null) { const i = list.indexOf(prefer); if (i >= 0) { list.splice(i, 1); return prefer; } } return list.shift() ?? null; } /** * 合成体占用格上已有同 weapon1 的部署实体。 * @param comp 合成体 * @param player 玩家槽 * @param session 战斗会话 * @param loadoutSvc 配装 */ private _findEntityOnCompGrids( comp: ComposedUnit, player: Player | null | undefined, session: BattleSession, loadoutSvc: TowerLoadoutService, ): EntityId | null { if (!player || !this._map) return null; for (let i = 0; i < comp.gridIndices.length; i++) { const eid = findEntityIdByGridIndex(comp.gridIndices[i], session, this._map, player); if (eid == null || !session.world.isAlive(eid)) continue; if (loadoutSvc.getLoadout(eid)?.weapon1Id !== comp.weapon1Id) continue; return eid; } return null; } /** * 将已有塔实体绑定到合成体锚点(搬家/升等级,不新建)。 * @param comp 合成体 * @param cell 逻辑格 * @param entityId 实体 * @param keep 本轮保留集 * @param caller 调用来源 * @param pid 玩家 * @param player 玩家槽 * @param session 会话 * @param loadoutSvc 配装 */ private _bindDeployEntity( comp: ComposedUnit, cell: { row: number; col: number }, entityId: EntityId, keep: MembershipSet, caller: string, pid: number, player: Player | null | undefined, session: BattleSession, loadoutSvc: TowerLoadoutService, ): void { let atCell = session.grid.getCell(cell)?.occupant ?? null; if (atCell != null && !session.world.isAlive(atCell)) { session.grid.clearOccupant(atCell); atCell = null; } this._evictBlockingOccupantsOnComposition(comp, keep, entityId); if (atCell != null && atCell !== entityId) { session.grid.clearOccupant(atCell); } this._relocateTower(entityId, cell, comp); membershipAdd(keep, entityId); this._syncHeroActor(entityId, comp); const loadout = loadoutSvc.getLoadout(entityId); if (loadout && loadout.level !== comp.level && player) { setupTowerCombat(session, entityId, comp.weapon1Id, comp.level, player); } else { getBattleBuffDigitStatHost().refreshEntity(session, entityId, pid); } } /** 同 Weapon1 塔改锚点:只搬家,不销毁实体(保留 CD / 进行中技能) */ private _relocateTower( entityId: EntityId, anchor: { row: number; col: number }, comp: ComposedUnit, ): void { const session = this._runtime?.session; if (!session) return; const tf = session.world.getComponent(entityId, TransformComp); if (tf) { tf.row = anchor.row; tf.col = anchor.col; } this._syncCompositionOccupancy(entityId, anchor, comp); } /** 新方案:塔实体对账完成后立刻建/刷新 heroTemplate(格子皮已藏,不能等晚一帧 SkillController) */ private _syncHeroActor(entityId: EntityId, comp: ComposedUnit): void { if (!isHeroActorWeapon1(comp.weapon1Id) || !this._map) return; HeroActorHost.instance.syncFromMap(entityId, comp, this._map, this._playerId !== 0); } /** 合成体占地:同步 GridMap.occupy 与 GridOccupancyComp.cells(锚点单格) */ private _syncCompositionOccupancy( entityId: EntityId, anchor: { row: number; col: number }, comp: ComposedUnit, ): void { const session = this._runtime?.session; if (!session) return; const cells: { row: number; col: number }[] = [this._cellOf(comp) ?? anchor]; for (let i = 0; i < cells.length; i++) { const c = cells[i]; const occ = session.grid.getCell(c)?.occupant ?? null; if (occ == null || occ === entityId) continue; if (!session.world.isAlive(occ)) { session.grid.clearOccupant(occ); continue; } const team = session.world.getComponent(occ, TeamComp); if (team?.ownerId === this._playerId && session.deploy.sell(this._playerId, occ)) { getTowerLoadoutService().removeEntity(occ); HeroActorHost.instance.destroy(occ); } else { session.grid.clearOccupant(occ); } } session.grid.clearOccupant(entityId); session.grid.occupy(cells, entityId); const occ = session.world.getComponent(entityId, GridOccupancyComp); if (occ) { occ.anchor = { ...anchor }; occ.cells = cells.map(c => ({ ...c })); } session.spatial.updateEntity(entityId, anchor); } /** @deprecated 查询请用 DeployEntityQuery + Player.compositions */ getDeployed(_entityId: number): DeployedTowerInfo | null { return null; } /** 当前全部已部署实体(PlayerSlot.deployedUnits) */ getDeployedEntityIds(): readonly number[] { return this._runtime?.getPlayer(0)?.deployedUnits ?? []; } /** @deprecated 查询请用 DeployEntityQuery.findEntityIdByGridIndex */ getEntityIdByGridIndex(_gridIndex: number): number | null { return null; } /** @deprecated 战斗结束由 session 销毁,无需清缓存 */ clear(): void {} private _cellOf(comp: ComposedUnit): { row: number; col: number } | null { if (!this._map || !this._runtime) return null; // 锚点取最小 gridIndex,避免 compositions 数组顺序抖动导致来回改位 let gridIndex = -1; for (let i = 0; i < comp.gridIndices.length; i++) { const g = comp.gridIndices[i]; if (gridIndex < 0 || g < gridIndex) gridIndex = g; } if (gridIndex < 0) return null; const g = this._map.getMapGridByIndex(gridIndex); if (!g) return null; return mapGridToLogicCell(g.Row, g.Col, this._runtime.session.grid.rows, this._runtime.session.grid.cols); } private _markComposeCellBuildable(_comp: ComposedUnit, cell: { row: number; col: number }): void { const session = this._runtime?.session; if (!session) return; session.grid.setBuildable(cell, true); } private _reconcileUntrackedOccupant(cell: { row: number; col: number }): void { const session = this._runtime?.session; if (!session) return; const state = session.grid.getCell(cell); const occ = state?.occupant; if (occ == null) return; if (!session.world.isAlive(occ)) { session.grid.clearOccupant(occ); return; } const team = session.world.getComponent(occ, TeamComp); if (team?.ownerId !== this._playerId) return; if (session.deploy.sell(this._playerId, occ)) { getTowerLoadoutService().removeEntity(occ); HeroActorHost.instance.destroy(occ); refreshWangMuFieldAtkSteal(session); } } } export type { DeployedTowerInfo } from './DeployEntityQuery';