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.
 
 
 
 

625 lines
25 KiB

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 { 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<EntityId>();
const loadoutSvc = getTowerLoadoutService();
const pid = this._playerId;
// 按 Weapon1 收集已部署实体,移动时复用(禁止卖了再建导致 CD/大招被清)
const poolByWeapon = new Map<number, EntityId[]>();
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<EntityId>,
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<EntityId>, 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<number, EntityId[]>, 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<EntityId>,
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);
}
}
/** 同 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';