import { Label, Node, Tween, tween, UIOpacity, Vec3 } from 'cc'; import { GEvent } from 'db://assets/mx/module/event/GEvent'; import { BattleAudio } from '../../battle/audio/BattleAudio'; import { GBundle } from '../../game/ConfigRes'; import { CoinFly } from './CoinFly'; /** 单次最多飘出 coinFly 数量 */ const MAX_FLY_COUNT = 8; /** 多枚依次起飞间隔(秒) */ const FLY_STAGGER_SEC = 0.05; /** 波次结算每枚金币的冒出间隔(秒) */ const WAVE_COIN_SPAWN_GAP_SEC = 0.15; /** 波次结算金币起点相对屏幕中心的世界 Y 偏移 */ const WAVE_COIN_START_OFFSET_Y = 150; /** 波次金币冒出时的水平散开(世界像素) */ const WAVE_POP_SPREAD_X = 140; /** 波次金币冒出时的垂直散开(世界像素) */ const WAVE_POP_SPREAD_Y = 70; /** coinFly prefab 默认 scale=0.6;原放大×2,再缩小 40% */ const FLY_VISUAL_SCALE = 0.6 * 2 * 0.6; /** +1 相对桃子图标右侧本地偏移(父节点已缩放) */ const PLUS_OFFSET_X = 40 / FLY_VISUAL_SCALE; /** 挂在桃子上的 +1 节点名(回池前拆掉) */ const PLUS_CHILD = 'dropPlusOne'; /** * 战斗内金币飘字:使用 battleGame/prefab/coinFly。 * 起点击杀/回收点,终点底部 btnCoin/金币。 */ export class BattlePeachCoinFlyPresenter { private _moneyNode: Node | null = null; private _flyLayer: Node | null = null; private _flyPending = 0; private _flyComplete: (() => void) | null = null; /** 掉落 +1 飘字 */ private readonly _plusQueue: Node[] = []; /** 波次金币还待生成的枚数 */ private _waveLeft = 0; /** 波次金币单枚显示数量 */ private _waveShowGold = 0; /** 波次金币父节点 */ private _waveLayer: Node | null = null; /** 波次金币起点世界坐标 */ private readonly _waveStart = new Vec3(); /** 已冒出、尚未一起飞的波次金币 */ private readonly _waveCoins: Node[] = []; /** 本轮已生成枚数 */ private _waveSpawned = 0; /** 本轮已完成冒出的枚数 */ private _wavePopped = 0; /** 间隔生成已结束,等全部冒出后再一起飞 */ private _waveSpawnFinished = false; /** 正在冒出并等待一起飞 */ private _waveAwaitFly = false; /** 绑定飞向目标(btnCoin/金币)与飞行层(ui_fly) */ bind(moneyNode: Node | null | undefined, flyLayer: Node | null | undefined): void { this._moneyNode = moneyNode?.isValid ? moneyNode : null; this._flyLayer = flyLayer?.isValid ? flyLayer : null; } /** 注销 */ unbind(): void { this._stopWaveSpawn(); this._recycleAllPlus(); this._moneyNode = null; this._flyLayer = null; this._flyPending = 0; this._flyComplete = null; } /** * 从节点世界坐标飘仙桃到 ui_money。 * @param startNode skill1 / skill2 根节点 * @param peachCount 回收蟠桃数量(决定飘出枚数上限) * @param onAllArrived 全部到达后回调(在此发放蟠桃) */ playFromNode(startNode: Node | null | undefined, peachCount: number, onAllArrived: () => void): void { if (peachCount <= 0) { onAllArrived(); return; } const start = startNode?.isValid ? startNode.worldPosition.clone() : null; if (!start) { console.log('[BattlePeachCoinFly] 起点无效,跳过飘字'); onAllArrived(); return; } this._playFromWorldStart(start, peachCount, onAllArrived); } /** * 从世界坐标飘仙桃到 ui_money。 * @param startWorld 起点世界坐标 * @param peachCount 蟠桃数量 * @param onAllArrived 全部到达后回调 */ playFromWorld(startWorld: Readonly, peachCount: number, onAllArrived: () => void): void { if (peachCount <= 0) { onAllArrived(); return; } this._playFromWorldStart(startWorld.clone(), peachCount, onAllArrived); } /** * 波次结算:金币在不同位置间隔冒出,全部冒完后一起飞向 btnCoin。 * @param flyLayer BattleGame.ui_fly * @param startWorld 起点世界坐标 * @param showGold 单枚金币上的显示数量 * @param count 生成枚数 */ playWaveCoin(flyLayer: Node, startWorld: Readonly, showGold: number, count: number): void { if (!flyLayer?.isValid) { console.log('[BattlePeachCoinFly] 波次金币缺少 ui_fly'); return; } const amount = Math.floor(showGold); const flyCount = Math.floor(count); if (!(amount > 0) || !(flyCount > 0)) { console.log('[BattlePeachCoinFly] 波次金币数量非法', showGold, count); return; } this._stopWaveSpawn(); this._waveLeft = flyCount; this._waveShowGold = amount; this._waveLayer = flyLayer; this._waveStart.set(startWorld.x, startWorld.y + WAVE_COIN_START_OFFSET_Y, startWorld.z); this._waveSpawned = 0; this._wavePopped = 0; this._waveSpawnFinished = false; this._waveAwaitFly = true; BattleAudio.playCoinFly(); this._spawnOneWaveCoin(); } /** 在散开位置冒出一枚波次金币,剩余的等间隔再冒 */ private _spawnOneWaveCoin(): void { if (!this._waveAwaitFly || this._waveLeft <= 0) { this._stopWaveSpawn(); return; } const layer = this._waveLayer; if (!layer?.isValid) { console.log('[BattlePeachCoinFly] 波次金币缺少 ui_fly'); this._stopWaveSpawn(); return; } const world = new Vec3( this._waveStart.x + (Math.random() - 0.5) * WAVE_POP_SPREAD_X, this._waveStart.y + (Math.random() - 0.5) * WAVE_POP_SPREAD_Y, this._waveStart.z, ); const node = this._createCoinNode(layer, world, `+${this._waveShowGold}`); if (!node) { console.log('[BattlePeachCoinFly] 波次金币未生成'); this._stopWaveSpawn(); return; } const comp = node.getComponent(CoinFly); if (!comp) { console.log('[BattlePeachCoinFly] coinFly 缺少 CoinFly 组件'); this._recycleWaveCoin(node); this._stopWaveSpawn(); return; } this._waveCoins.push(node); this._waveSpawned++; this._waveLeft--; comp.popStay(this._onOneWavePopped); if (this._waveLeft <= 0) { this._waveSpawnFinished = true; this._tryFlyWaveTogether(); return; } tween(this).delay(WAVE_COIN_SPAWN_GAP_SEC).call(this._onWaveCoinGap).start(); } /** 间隔结束,再冒出一枚波次金币 */ private readonly _onWaveCoinGap = (): void => { this._spawnOneWaveCoin(); }; /** 一枚波次金币冒出结束 */ private readonly _onOneWavePopped = (): void => { if (!this._waveAwaitFly) return; this._wavePopped++; this._tryFlyWaveTogether(); }; /** 全部冒出后,一起飞向 btnCoin */ private _tryFlyWaveTogether(): void { if (!this._waveAwaitFly || !this._waveSpawnFinished) return; if (this._wavePopped < this._waveSpawned) return; this._waveAwaitFly = false; const money = this._moneyNode; const end = money?.isValid ? money.worldPosition.clone() : null; const coins = this._takeWaveCoins(); this._clearWaveSpawn(); if (!end) { console.log('[BattlePeachCoinFly] 波次金币缺少飞向目标'); for (let i = 0; i < coins.length; i++) this._recycleWaveCoin(coins[i]); return; } for (let i = 0; i < coins.length; i++) { const node = coins[i]; if (!node?.isValid) continue; const comp = node.getComponent(CoinFly); if (!comp) { this._recycleWaveCoin(node); continue; } comp.flyTo(end, this._onPeachDone); } } /** * 取出一枚金币并挂上数字,先不飞。 * @param layer 父节点 * @param world 世界坐标 * @param plusText 金币上的数字 */ private _createCoinNode(layer: Node, world: Readonly, plusText: string): Node | null { if (!layer.active) layer.active = true; const node = gg.res.getNode('prefab/', 'coinFly', GBundle.BattleGame); if (!node?.isValid) { console.log('[BattlePeachCoinFly] coinFly 节点池不足'); return null; } layer.addChild(node); node.setScale(FLY_VISUAL_SCALE, FLY_VISUAL_SCALE, 1); node.setWorldPosition(world.x, world.y, world.z); this._attachPlus(node, plusText); return node; } /** * 回池一枚尚未飞走的波次金币。 * @param node 金币节点 */ private _recycleWaveCoin(node: Node | null): void { if (!node?.isValid) return; Tween.stopAllByTarget(node); this._putPlusChild(node); gg.res.putNode(node); } /** 取出并清空等待一起飞的金币,避免清长度时把同一数组引用一起清空 */ private _takeWaveCoins(): Node[] { const coins: Node[] = []; for (let i = 0; i < this._waveCoins.length; i++) coins.push(this._waveCoins[i]); this._waveCoins.length = 0; return coins; } /** 停掉尚未一起飞的波次金币 */ private _stopWaveSpawn(): void { Tween.stopAllByTarget(this); this._waveAwaitFly = false; const coins = this._takeWaveCoins(); for (let i = 0; i < coins.length; i++) this._recycleWaveCoin(coins[i]); this._clearWaveSpawn(); } /** 清空波次金币生成状态,不回收已经开始飞行的金币 */ private _clearWaveSpawn(): void { this._waveLeft = 0; this._waveShowGold = 0; this._waveLayer = null; this._waveSpawned = 0; this._wavePopped = 0; this._waveSpawnFinished = false; } /** * 击杀掉落飞桃:先弹跳再飞向 ui_money,不占用回收完成回调。 * @param startWorld 死亡世界坐标 * @param peachCount Monster.peach */ playKillDrop(startWorld: Readonly, peachCount: number): void { if (peachCount <= 0) return; BattleAudio.playCoinFly(); const n = this._spawnCoinFlies(startWorld.clone(), peachCount, null, true); if (n <= 0) { console.log('[BattlePeachCoinFly] 击杀掉桃未生成'); } } /** * AI 击杀掉落:原地弹跳后回池,不飞向本机 ui_money。 * @param startWorld 死亡世界坐标 * @param peachCount Monster.peach */ playKillDropLocal(startWorld: Readonly, peachCount: number): void { if (peachCount <= 0) return; const n = this._spawnCoinFlies(startWorld.clone(), peachCount, null, false); if (n <= 0) { console.log('[BattlePeachCoinFly] AI 击杀掉桃未生成'); } } /** * 乾坤袋回收:与怪物死亡产桃相同(coinFly 弹跳 + battleText「+1」飞向 ui_money)。 * @param startWorld 起点世界坐标 * @param peachCount 回收蟠桃数量 * @param onAllArrived 全部到达 ui_money 后回调 */ playRecycleFromWorld(startWorld: Readonly, peachCount: number, onAllArrived: () => void): void { this.playFromWorld(startWorld, peachCount, onAllArrived); } /** 从世界坐标飘仙桃到 ui_money(coinFly,多枚) */ private _playFromWorldStart(startWorld: Vec3, peachCount: number, onAllArrived: () => void): void { this._flyPending = 0; this._flyComplete = onAllArrived; const spawned = this._spawnCoinFlies(startWorld, peachCount, this._onOneCoinFlyArrived.bind(this), true); if (spawned <= 0) { this._flyComplete = null; onAllArrived(); } } /** * 生成 coinFly:先原地弹跳;toMoney 时再飞向 ui_money。 * @param startWorld 起点 * @param peachCount 桃子数量 * @param onArrive 单枚到达;击杀掉落可空 * @param toMoney 是否飞向本机 ui_money * @param flySec 飞向终点时长;≤0 为击杀掉落的短飞行 * @param layer 指定父节点;缺省用已绑定的飞行层 * @param plusText 金币上的数字;缺省为击杀掉落的 +1 * @param maxCount 本次最多生成枚数;波次结算按配置次数,击杀掉落沿用上限 */ private _spawnCoinFlies(startWorld: Vec3, peachCount: number, onArrive: (() => void) | null, toMoney: boolean, flySec = 0, layer: Node | null = null, plusText = '+1', maxCount = MAX_FLY_COUNT): number { const flyLayer = layer?.isValid ? layer : this._flyLayer; const moneyNode = this._moneyNode; if (!flyLayer?.isValid) { console.log('[BattlePeachCoinFly] flyLayer 未绑定,跳过飘字'); return 0; } if (toMoney && !moneyNode?.isValid) { console.log('[BattlePeachCoinFly] ui_money 未绑定,跳过飘字'); return 0; } if (!flyLayer.active) flyLayer.active = true; const endPos = toMoney && moneyNode?.isValid ? moneyNode.worldPosition.clone() : null; const flyCount = Math.min(Math.max(1, Math.floor(peachCount)), Math.floor(maxCount)); let spawned = 0; for (let i = 0; i < flyCount; i++) { const node = gg.res.getNode('prefab/', 'coinFly', GBundle.BattleGame); if (!node?.isValid) { console.log('[BattlePeachCoinFly] coinFly 节点池不足', i); continue; } flyLayer.addChild(node); node.setScale(FLY_VISUAL_SCALE, FLY_VISUAL_SCALE, 1); node.setWorldPosition( startWorld.x + (Math.random() - 0.5) * 40, startWorld.y + (Math.random() - 0.5) * 30, startWorld.z, ); const comp = node.getComponent(CoinFly); if (!comp) { console.log('[BattlePeachCoinFly] coinFly 缺少 CoinFly 组件'); gg.res.putNode(node); continue; } spawned++; if (onArrive) this._flyPending++; this._attachPlus(node, plusText); comp.flyAni(endPos, this._onPeachDone, i * FLY_STAGGER_SEC, flySec); } return spawned; } /** * 把数字挂到金币右侧,跟随弹跳并飞向 btnCoin。 * @param peach 金币节点 * @param text 显示文案,如 +110 */ private _attachPlus(peach: Node, text: string): void { let node: Node | null = null; try { node = gg.res.getNode('prefab/', 'battleText', GBundle.BattleGame); } catch (err) { console.log('[BattlePeachCoinFly] battleText 取节点失败', String(err)); return; } if (!node?.isValid) { console.log('[BattlePeachCoinFly] battleText 预制体缺失'); return; } const lab = node.getComponent(Label); if (!lab) { console.log('[BattlePeachCoinFly] battleText 无 Label'); gg.res.putNode(node); return; } lab.string = text; const arrow = node.getChildByName('上箭头'); if (arrow) arrow.active = false; let fade = node.getComponent(UIOpacity); if (!fade) fade = node.addComponent(UIOpacity); fade.opacity = 255; node.name = PLUS_CHILD; node.parent = peach; node.active = true; node.setScale(1 / FLY_VISUAL_SCALE, 1 / FLY_VISUAL_SCALE, 1); node.setPosition(PLUS_OFFSET_X, 0, 0); this._plusQueue.push(node); } /** 桃子动画结束:拆 +1,再走到达计数 */ private readonly _onPeachDone = (peach?: Node): void => { if (peach?.isValid) this._putPlusChild(peach); if (this._flyPending > 0) this._onOneCoinFlyArrived(); }; /** * 从桃子上拆下 +1 并回池。 * @param peach 桃子节点 */ private _putPlusChild(peach: Node): void { const plus = peach.getChildByName(PLUS_CHILD); if (!plus?.isValid) return; this._removePlusFromQueue(plus); this._putPlus(plus); } /** * 从跟踪队列移除。 * @param node +1 节点 */ private _removePlusFromQueue(node: Node): void { for (let i = 0; i < this._plusQueue.length; i++) { if (this._plusQueue[i] !== node) continue; this._plusQueue.splice(i, 1); return; } } /** 回收全部 +1 */ private _recycleAllPlus(): void { for (let i = 0; i < this._plusQueue.length; i++) { this._putPlus(this._plusQueue[i]); } this._plusQueue.length = 0; } /** * 回池单条 +1。 * @param node battleText 节点 */ private _putPlus(node: Node): void { if (!node?.isValid) return; Tween.stopAllByTarget(node); const fade = node.getComponent(UIOpacity); if (fade) { Tween.stopAllByTarget(fade); fade.opacity = 255; } node.removeFromParent(); gg.res.putNode(node); } /** 单枚 coinFly 到达 ui_money */ private _onOneCoinFlyArrived(): void { this._flyPending = Math.max(0, this._flyPending - 1); if (this._flyPending > 0) return; const done = this._flyComplete; this._flyComplete = null; done?.(); GEvent.Ins.emit(GEvent.UpdateGameCoinNum); } }