import { Vec3, type Node } from 'cc'; import type { DragSource, DropTarget, PlaceRejectReason } from 'db://assets/mx/module/merge'; import { BagToMapPlaceAnimator } from './BagToMapPlaceAnimator'; import { ClientErrorReporter } from '../../../mx/tools/ClientErrorReporter'; import { DeviceProfile } from './DeviceProfile'; import type { GridDetachSource } from './BoardGridRegistry'; import type { TextGrid } from './TextGrid'; /** 单次飞行落点 */ export interface BoardPlaceFlightLeg { /** 飞行的字块 */ grid: TextGrid; /** 落点世界坐标 */ targetWorld: Readonly; /** 动画结束后挂接父节点;合成时不挂(格上已有真身) */ attachParent?: Node | null; /** 落地世界坐标(地图字挂 ui_textGrids 时用) */ restWorld?: Readonly | null; /** 起飞后清空原槽/格残留 */ vacateFrom: GridDetachSource; /** 起点世界坐标 */ startWorld?: Readonly | null; } /** 落子成功后的 rebuild 策略 */ export type BoardPlaceSuccessRebuild = 'flush' | 'requestSession' | 'none'; /** 落子飞行动画执行回调 */ export interface BoardPlaceFlightHooks { /** drop 前:禁止 MergeEvent 触发全量 rebuild */ beginDropViewSync(): void; /** drop 失败:复位同步阶段 */ cancelDropViewSync(): void; /** 动画批次开始 */ onInflightStart(): void; /** 世代守卫 */ finishPlaceFlightGuard(flightGen: number): boolean; /** 逻辑落子 */ drop(source: DragSource, target: DropTarget): PlaceRejectReason | null; /** 视图提交 */ commitViews( grid: TextGrid, target: DropTarget, swap?: { grid: TextGrid; target: DropTarget } | null, ): void; /** drop 前额外校验;返回非空则失败 */ validateBeforeDrop?(): PlaceRejectReason | 'rebuild' | null; /** 落子被拒时的提示 */ onDropRejected?(reason: PlaceRejectReason): void; /** 失败时全量重建 */ rebuildOnFailure(): void; /** 冲刷暂缓 rebuild */ flushPendingRebuilds(): void; /** 请求全量 session rebuild */ requestSessionRebuild?(): void; /** 回收孤儿 */ disposeOrphan(grid: TextGrid): void; /** 成功后的 rebuild 策略 */ onSuccessRebuild: BoardPlaceSuccessRebuild; /** 落子成功且 rebuild 冲刷后(背包→地图等待绑定攻速符等) */ afterDropSuccess?(source: DragSource, target: DropTarget): void; /** 外部完成回调 */ onComplete?(ok: boolean): void; /** 动画前清理(慢动作/幽灵等) */ beforeAnim?(): void; /** 每个 leg 动画前 */ onLegPrepare?(grid: TextGrid): void; /** 清空原槽/格残留节点 */ purgeVacatedCell(from: GridDetachSource): void; } /** 落子飞行动画请求 */ export interface BoardPlaceFlightRequest { dragLayer: Node; flightGen: number; dropSource: DragSource; dropTarget: DropTarget; primaryLeg: BoardPlaceFlightLeg; swapLeg?: BoardPlaceFlightLeg | null; swapCommit?: { grid: TextGrid; target: DropTarget } | null; hooks: BoardPlaceFlightHooks; } /** 飞行动画最长等待(毫秒);tween 丢失回调时强制落地,避免 rebuild/操作永久锁死 */ const PLACE_FLIGHT_SAFETY_MS = 800; /** * 统一背包↔地图 / 交换 / 落子飞行动画管线: * animate → drop → commit → rebuild。 */ export class BoardPlaceFlightExecutor { /** 播放飞行动画并在结束后落子 */ static execute(req: BoardPlaceFlightRequest): void { const { dragLayer, primaryLeg, swapLeg, hooks, } = req; hooks.onInflightStart(); hooks.beforeAnim?.(); hooks.onLegPrepare?.(primaryLeg.grid); if (swapLeg) hooks.onLegPrepare?.(swapLeg.grid); let pending = swapLeg ? 2 : 1; let settled = false; const failState = { failed: false }; let safetyTimer: ReturnType | null = null; const onAnimDone = (): void => { if (settled) return; pending--; if (pending > 0) return; settled = true; if (safetyTimer != null) clearTimeout(safetyTimer); BoardPlaceFlightExecutor._completeFlight(req, failState); }; safetyTimer = setTimeout(() => { if (settled) return; console.warn('[BoardPlaceFlight] 飞行动画超时,强制落地'); pending = 0; onAnimDone(); }, PLACE_FLIGHT_SAFETY_MS); BagToMapPlaceAnimator.play(primaryLeg.grid, primaryLeg.targetWorld, dragLayer, onAnimDone, { attachParent: primaryLeg.attachParent, restWorld: primaryLeg.restWorld, startWorld: primaryLeg.startWorld, showTrail: DeviceProfile.enableBoardPlaceTrail, }); hooks.purgeVacatedCell(primaryLeg.vacateFrom); if (swapLeg) { BagToMapPlaceAnimator.play(swapLeg.grid, swapLeg.targetWorld, dragLayer, onAnimDone, { attachParent: swapLeg.attachParent, restWorld: swapLeg.restWorld, startWorld: swapLeg.startWorld, showTrail: DeviceProfile.enableBoardPlaceTrail, }); hooks.purgeVacatedCell(swapLeg.vacateFrom); } } /** * 动画结束:drop / commit / rebuild。 * @param req 飞行请求 * @param failState 落子失败标记 */ private static _completeFlight(req: BoardPlaceFlightRequest, failState: { failed: boolean }): void { const { flightGen, dropSource, dropTarget, primaryLeg, swapLeg, swapCommit, hooks } = req; if (!hooks.finishPlaceFlightGuard(flightGen)) { hooks.disposeOrphan(primaryLeg.grid); if (swapLeg) hooks.disposeOrphan(swapLeg.grid); primaryLeg.grid.finishPlaceFlightVisual(); swapLeg?.grid.finishPlaceFlightVisual(); hooks.onComplete?.(false); return; } try { const pre = hooks.validateBeforeDrop?.(); if (pre === 'rebuild') { failState.failed = true; primaryLeg.grid.restoreOrigin(); hooks.cancelDropViewSync(); } else if (pre) { failState.failed = true; primaryLeg.grid.restoreOrigin(); hooks.onDropRejected?.(pre); hooks.cancelDropViewSync(); } else { hooks.beginDropViewSync(); const reason = hooks.drop(dropSource, dropTarget); if (reason) { failState.failed = true; primaryLeg.grid.restoreOrigin(); hooks.onDropRejected?.(reason); hooks.cancelDropViewSync(); } else { hooks.commitViews(primaryLeg.grid, dropTarget, swapCommit ?? undefined); } } } catch (e) { failState.failed = true; ClientErrorReporter.report('BoardPlaceFlight_dropCommit', e); hooks.cancelDropViewSync(); } finally { if (failState.failed) { hooks.rebuildOnFailure(); } else if (hooks.onSuccessRebuild === 'requestSession') { hooks.requestSessionRebuild?.(); hooks.flushPendingRebuilds(); } else if (hooks.onSuccessRebuild === 'flush') { hooks.flushPendingRebuilds(); } if (!failState.failed) { hooks.afterDropSuccess?.(dropSource, dropTarget); } primaryLeg.grid.finishPlaceFlightVisual(); swapLeg?.grid.finishPlaceFlightVisual(); hooks.disposeOrphan(primaryLeg.grid); if (swapLeg) hooks.disposeOrphan(swapLeg.grid); hooks.onComplete?.(!failState.failed); } } }