/** * @file BattleBannerComp.ts * @description 战斗横幅统一通道组件(表现层) * * 职责: * 1. 监听清屏(WaveClear)/ Boss 预警(BossWarning)/ 连杀(ComboReach)事件,播分级横幅。 * 2. 横幅动画:右侧飞入(backOut)→ 中央停留 → 左侧飞出(backIn),与 MissionComp.playTooltipAnim 同风格。 * 3. 清屏奖励结算(fastClear 金币 / allAlive 刷新石),走 MissionEconomy 静态接口。 * 4. Boss 登场(BossSpawn)触发震屏 + 音效。 * * 使用 oops-framework 模块:oops.message(事件解耦)、oops.audio(音效)。 * 编辑器绑定:bannerNode(Label+背景节点,初始 active=false)。 */ import { _decorator, Node, Label, tween, Tween, v3, Color } from "cc"; import { oops } from "../../../../extensions/oops-plugin-framework/assets/core/Oops"; import { CCComp } from "../../../../extensions/oops-plugin-framework/assets/module/common/CCComp"; import { GameEvent } from "../common/config/GameEvent"; import { MissionEconomy } from "./MissionEconomy"; import { ScreenShake } from "../common/ScreenShake"; import { WAVE_DURATION } from "./RogueConfig"; const { ccclass, property } = _decorator; /** 清屏奖励:迅速清场(< 75% 时长) */ const FAST_CLEAR_COIN = 2; /** 清屏奖励:极速清场(< 50% 时长) */ const ULTRA_CLEAR_COIN = 5; /** 连杀分级金币爆发(tier 0/1/2 对应 5/10/20 连杀) */ const COMBO_COIN = [2, 5, 10]; @ccclass('BattleBannerComp') export class BattleBannerComp extends CCComp { /** 横幅节点(Label+背景,初始隐藏),编辑器拖拽绑定 */ @property({ type: Node, tooltip: "横幅节点(Label+背景,初始 active=false)" }) bannerNode: Node | null = null; /** 上次横幅播放时间戳(去抖:0.3s 内的新横幅直接打断旧的,不叠加 tween) */ private lastBannerTime: number = 0; onLoad() { // oops.message 全局事件总线:清屏 / Boss 预警 / Boss 登场 / 连杀 oops.message.on(GameEvent.WaveClear, this.onWaveClear, this); oops.message.on(GameEvent.BossWarning, this.onBossWarning, this); oops.message.on(GameEvent.BossSpawn, this.onBossSpawn, this); oops.message.on(GameEvent.ComboReach, this.onComboReach, this); } onDestroy() { oops.message.off(GameEvent.WaveClear, this.onWaveClear, this); oops.message.off(GameEvent.BossWarning, this.onBossWarning, this); oops.message.off(GameEvent.BossSpawn, this.onBossSpawn, this); oops.message.off(GameEvent.ComboReach, this.onComboReach, this); } // ======================== 事件处理 ======================== /** 清屏庆祝:分级文案 + 奖励结算 */ private onWaveClear(event: string, data: { wave: number; clearTime: number; allAlive: boolean; fastClear: number }) { if (data.allAlive) { this.playBanner("Perfect!", new Color(255, 80, 80), 1.2); MissionEconomy.addRefreshStone(1); oops.audio.playEffect("music/flash"); } else if (data.fastClear === 2) { this.playBanner("极速清场!", new Color(255, 170, 0), 1.15); MissionEconomy.addCoin(ULTRA_CLEAR_COIN); oops.audio.playEffect("music/flash"); } else if (data.fastClear === 1) { this.playBanner("迅速清场!", new Color(255, 220, 60), 1.05); MissionEconomy.addCoin(FAST_CLEAR_COIN); } else { this.playBanner("Clear!", new Color(255, 255, 255), 1.0); } } /** Boss 回合预警:红字横幅 */ private onBossWarning(event: string, data: { wave: number; eta: number }) { this.playBanner("强敌来袭!", new Color(255, 60, 60), 1.3); oops.audio.playEffect("music/flash"); } /** Boss 登场:震屏 + 闷响 */ private onBossSpawn() { ScreenShake.shake(10, 0.4); oops.audio.playEffect("music/dun"); } /** 连杀达阈值:分级文案 + 震屏 + 金币爆发 */ private onComboReach(event: string, data: { count: number; tier: number }) { const tier = Math.min(data.tier, 2); const colors = [new Color(255, 230, 80), new Color(255, 150, 30), new Color(255, 60, 60)]; const scales = [1.0, 1.2, 1.4]; this.playBanner(`${data.count} 连杀!`, colors[tier], scales[tier]); if (tier === 1) ScreenShake.shake(4, 0.2); if (tier === 2) ScreenShake.shake(8, 0.3); MissionEconomy.addCoin(COMBO_COIN[tier]); // 占位音效分级(后续可替换为升调 combo 音效) oops.audio.playEffect(tier === 2 ? "music/Fire" : tier === 1 ? "music/Critical" : "music/Hit"); } // ======================== 横幅播放 ======================== /** * 播放横幅:右进 → 中央停留 → 左出 * @param text 文案 * @param color 文字颜色 * @param scale 整体缩放(分级表现力) */ private playBanner(text: string, color: Color, scale: number = 1.0) { if (!this.bannerNode || !this.bannerNode.isValid) return; // 去抖:0.3s 内的新横幅直接打断旧的 const now = Date.now(); if (now - this.lastBannerTime < 300) { Tween.stopAllByTarget(this.bannerNode); } this.lastBannerTime = now; this.bannerNode.active = true; this.bannerNode.setScale(v3(scale, scale, 1)); const label = this.bannerNode.getComponentInChildren(Label); if (label) { label.string = text; label.color = color; label.updateRenderData(true); } Tween.stopAllByTarget(this.bannerNode); const startPos = v3(1200, 0, 0); const centerPos = v3(0, 0, 0); const endPos = v3(-1200, 0, 0); this.bannerNode.setPosition(startPos); tween(this.bannerNode) .to(0.4, { position: centerPos }, { easing: "backOut" }) .to(0.8, { position: v3(-40, 0, 0) }, { easing: "sineInOut" }) .to(0.35, { position: endPos }, { easing: "backIn" }) .call(() => { this.bannerNode!.active = false; }) .start(); } }