import { Node, sp, tween, Tween, Vec3, v3 } from 'cc'; import { GBundle } from '../../game/ConfigRes'; import { BATTLE_HERO_BUFF_PATH } from '../../battle/res/BattleHeroResCollector'; import { BattleSpinePlayback } from '../../battle/presentation/BattleSpinePlayback'; import { schedulePresentation } from '../../battle/skill/BattlePresentationClock'; import type { BattleBuffSkillDropTarget, CocosBagBoardView } from '../../battle/view/CocosBagBoardView'; import type { TextGrid } from '../../battle/view/TextGrid'; import type { BattlePeachCoinFlyPresenter } from './BattlePeachCoinFlyPresenter'; /** 乾坤袋预制体目录 */ const QIANKUNDAI_PREFAB_DIR = BATTLE_HERO_BUFF_PATH; /** 乾坤袋预制体名 */ const QIANKUNDAI_PREFAB_NAME = '乾坤袋'; /** 乾坤袋 Spine 动画名 */ const QIANKUNDAI_ANIM_NAME = 'animation'; /** 乾坤袋动画与字块吸入并行:延迟后开始移动(逻辑秒) */ const RECYCLE_TEXT_START_DELAY_SEC = 1; /** 字块吸入偏移(本地坐标) */ const RECYCLE_TEXT_OFFSET_X = 73; const RECYCLE_TEXT_OFFSET_Y = 73; /** 字块缩放消失时长(秒) */ const RECYCLE_TEXT_FLY_SEC = 0.35; /** 单次乾坤袋回收演出 */ type RecycleToPeachSession = { /** 会话 id */ id: number; /** 乾坤袋节点 */ bagFxNode: Node | null; /** 展示中的字块 */ grid: TextGrid; /** 倒计时世代 */ scheduleGen: number; }; /** * 乾坤袋主动 buff:skill 拖到字块上回收,播乾坤袋 → 字块吸入 → 仙桃飘字。 * CD 结束即可再次使用,不等待表现播完。 */ export class BattleGameRecycleToPeachPresenter { private _bagView: CocosBagBoardView | null = null; private _flyLayer: Node | null = null; private _peachFly: BattlePeachCoinFlyPresenter | null = null; /** 并行演出会话 */ private _sessions: RecycleToPeachSession[] = []; /** 会话 id 自增 */ private _sessionIdSeq = 0; /** 并行倒计时世代(abort 时递增以作废待触发回调) */ private _scheduleGen = 0; /** 绑定背包视图、飞行层与仙桃飘字 */ bind( bagView: CocosBagBoardView | null, flyLayer: Node | null | undefined, peachFly: BattlePeachCoinFlyPresenter | null, ): void { this.unbind(); this._bagView = bagView; this._flyLayer = flyLayer?.isValid ? flyLayer : null; this._peachFly = peachFly; } /** 注销并中断进行中的演出 */ unbind(): void { this._abortAllSessions(); this._bagView = null; this._flyLayer = null; this._peachFly = null; } /** 是否仍有进行中的回收演出(仅诊断,不阻塞施放) */ get isPlaying(): boolean { return this._sessions.length > 0; } /** * 尝试在目标字块上播放乾坤袋回收。 * @param target 地图格 / 背包槽 * @param peachCount 发放蟠桃数量 * @param onSkillConsumed skill 进入 CD */ tryPlay( target: BattleBuffSkillDropTarget, peachCount: number, onSkillConsumed: () => void, ): boolean { const bagView = this._bagView; const flyLayer = this._flyLayer; if (!bagView || !flyLayer?.isValid || peachCount <= 0) return false; if (!bagView.canApplyBattleBuffRecycleTarget(target)) return false; const grid = bagView.getTextGridForDropTarget(target); if (!grid?.node?.isValid) { console.log(`[RecycleToPeach] 目标字块视图缺失 ${target.kind}`); return false; } const textWorld = bagView.liftTextGridForRecyclePresentation(grid, flyLayer); if (!textWorld) { console.log(`[RecycleToPeach] 字块离槽失败 ${target.kind}`); return false; } const bagFx = this._spawnQiankundaiFx(flyLayer, textWorld); if (!bagFx) { console.log('[RecycleToPeach] 乾坤袋预制体加载失败'); bagView.releaseRecyclePresentationGrid(grid); return false; } const session: RecycleToPeachSession = { id: ++this._sessionIdSeq, bagFxNode: bagFx, grid, scheduleGen: ++this._scheduleGen, }; this._sessions.push(session); onSkillConsumed(); const skel = bagFx.getComponent(sp.Skeleton) ?? bagFx.getComponentInChildren(sp.Skeleton); if (!skel?.isValid) { console.log('[RecycleToPeach] 乾坤袋缺少 sp.Skeleton'); this._abortSession(session.id); return false; } BattleSpinePlayback.play(skel, QIANKUNDAI_ANIM_NAME, { loop: false, onComplete: () => this._onBagAnimComplete(session.id), }); const scheduleGen = session.scheduleGen; schedulePresentation(RECYCLE_TEXT_START_DELAY_SEC, () => { if (!this._isSessionActive(session.id, scheduleGen)) return; this._beginTextFly(session, target, peachCount); }); return true; } /** 字块吸入袋口(与乾坤袋动画并行,1s 后触发) */ private _beginTextFly( session: RecycleToPeachSession, target: BattleBuffSkillDropTarget, peachCount: number, ): void { const grid = session.grid; if (!this._isSessionActive(session.id) || !grid.node?.isValid) { this._abortSession(session.id); return; } const textNode = grid.node; Tween.stopAllByTarget(textNode); const startPos = textNode.position.clone(); const endPos = v3( startPos.x + RECYCLE_TEXT_OFFSET_X, startPos.y + RECYCLE_TEXT_OFFSET_Y, startPos.z, ); tween(textNode) .to(RECYCLE_TEXT_FLY_SEC, { position: endPos, scale: v3(0, 0, 1) }, { easing: 'sineIn' }) .call(() => this._onTextFlyComplete(session.id, target, peachCount, textNode)) .start(); } /** 乾坤袋动画播完:回收预制体(与字块吸入并行,互不提前删除) */ private _onBagAnimComplete(sessionId: number): void { const session = this._findSession(sessionId); if (!session) return; this._releaseBagFx(session); session.bagFxNode = null; } /** 字块吸入完成:提交回收并发桃 */ private _onTextFlyComplete( sessionId: number, target: BattleBuffSkillDropTarget, peachCount: number, textNode: Node, ): void { const session = this._findSession(sessionId); const bagView = this._bagView; const peachFly = this._peachFly; if (!session || !bagView) { if (sessionId > 0) this._abortSession(sessionId); return; } const grid = session.grid; const peachStart = textNode.isValid ? textNode.worldPosition.clone() : null; bagView.applyBattleBuffRecycleTarget(target); bagView.releaseRecyclePresentationGrid(grid); this._removeSession(sessionId); const grantPeach = (): void => { bagView.grantBattlePeach(peachCount); }; if (peachStart && peachFly) { peachFly.playRecycleFromWorld(peachStart, peachCount, grantPeach); } else { grantPeach(); } } /** 在字块位置生成乾坤袋表现 */ private _spawnQiankundaiFx(flyLayer: Node, worldPos: Readonly): Node | null { if (!flyLayer.active) flyLayer.active = true; let node: Node | null = null; try { node = gg.res.getNode(QIANKUNDAI_PREFAB_DIR, QIANKUNDAI_PREFAB_NAME, GBundle.BattleHero); } catch (e) { console.log('[RecycleToPeach] 乾坤袋加载异常', String(e)); return null; } if (!node?.isValid) return null; node.parent = flyLayer; node.setWorldPosition(worldPos); node.setSiblingIndex(flyLayer.children.length - 1); node.active = true; return node; } /** 回收乾坤袋节点 */ private _releaseBagFx(session: RecycleToPeachSession): void { const node = session.bagFxNode; session.bagFxNode = null; if (!node?.isValid) return; const skel = node.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton); if (skel?.isValid) BattleSpinePlayback.clearPendingComplete(skel); Tween.stopAllByTarget(node); if (node['resKey']) gg.res.putNode(node); else node.destroy(); } /** 中断全部演出并清理 */ private _abortAllSessions(): void { this._scheduleGen++; const ids = this._sessions.map((s) => s.id); for (let i = 0; i < ids.length; i++) { this._abortSession(ids[i]); } } /** 中断单次演出 */ private _abortSession(sessionId: number): void { const session = this._findSession(sessionId); if (!session) return; this._releaseBagFx(session); const grid = session.grid; if (grid?.node?.isValid) { Tween.stopAllByTarget(grid.node); this._bagView?.releaseRecyclePresentationGrid(grid); } this._removeSession(sessionId); } /** 会话是否仍有效 */ private _isSessionActive(sessionId: number, scheduleGen?: number): boolean { const session = this._findSession(sessionId); if (!session) return false; if (scheduleGen != null && session.scheduleGen !== scheduleGen) return false; return true; } /** 查找会话 */ private _findSession(sessionId: number): RecycleToPeachSession | null { for (let i = 0; i < this._sessions.length; i++) { if (this._sessions[i].id === sessionId) return this._sessions[i]; } return null; } /** 移除会话记录 */ private _removeSession(sessionId: number): void { for (let i = this._sessions.length - 1; i >= 0; i--) { if (this._sessions[i].id === sessionId) { this._sessions.splice(i, 1); return; } } } }