/** * @file VictoryComp.ts * @description 战斗结算弹窗组件(UI 视图层) * * 通过 oops.gui.open(UIID.Victory, args) 打开。 * 展示段位徽章、总分滚动动画、段位进度与亮点成就,并提供重开 / 退出操作。 */ import { _decorator, instantiate, Label, Prefab, Node, Sprite, ProgressBar, Tween, tween, v3, UIOpacity } from "cc"; import { ecs } from "../../../../extensions/oops-plugin-framework/assets/libs/ecs/ECS"; import { CCComp } from "../../../../extensions/oops-plugin-framework/assets/module/common/CCComp"; import { oops } from "../../../../extensions/oops-plugin-framework/assets/core/Oops"; import { smc } from "../common/SingletonModuleComp"; import { GameEvent } from "../common/config/GameEvent"; import { HighlightSet, HighlightType, HighlightLevel } from "../common/config/HighlightSet"; import { getRankByScore, getNextRank, getRankProgress, ScoreRankInfo } from "../common/config/ScoreRankSet"; import { LangPrefix, lang, langf } from "../common/LangUtil"; import { mLogger } from "../common/Logger"; import { UIID } from "../common/config/GameUIConfig"; const { ccclass, property } = _decorator; /** 总分滚动动画时长(秒) */ const SCORE_TWEEN_DURATION = 1.6; /** 段位晋升动效时长(秒) */ const RANK_UP_DURATION = 0.5; /** * VictoryComp —— 战斗结算弹窗视图组件 * * 布局(自上而下): * rank_icon 段位徽章 * total 总分(score_label 滚动数字) * rank_progress 段位内进度条 * next_rank_label 下一段位提示 * gain_label +N 涨幅标签(仅破纪录时显示) * highlights 亮点成就标签容器 * btns 操作按钮 */ @ccclass('VictoryComp') @ecs.register('Victory', false) export class VictoryComp extends CCComp { // ======================== 结算 UI 绑定 ======================== @property({ type: Sprite, tooltip: "段位徽章图标(rank_icon 节点上的 Sprite)" }) rank_icon: Sprite = null!; @property({ type: Node, tooltip: "撒花装饰节点(fireworks,循环缩放弹跳)" }) fireworks: Node = null!; @property({ type: Node, tooltip: "光晕装饰节点(halo,持续旋转)" }) halo: Node = null!; @property({ type: Label, tooltip: "总分文本(total/score_label)" }) total_score_label: Label = null!; @property({ type: ProgressBar, tooltip: "段位进度条(rank_progress)" }) rank_progress: ProgressBar = null!; @property({ type: Label, tooltip: "下一段位提示文本(next_rank_label)" }) next_rank_label: Label = null!; @property({ type: Label, tooltip: "涨幅标签(gain_label,仅破纪录时显示)" }) gain_label: Label = null!; @property({ type: Node, tooltip: "亮点成就标签的容器" }) highlights_container: Node = null!; @property({ type: Prefab, tooltip: "亮点成就标签预制体" }) highlight_prefab: Prefab = null!; /** 调试日志开关 */ debugMode: boolean = false; /** 奖励等级(预留) */ reward_lv: number = 1 /** 奖励数量(预留) */ reward_num: number = 2 /** 掉落奖励列表 */ rewards: any[] = [] /** 累计游戏数据(经验 / 金币 / 钻石) */ game_data: any = { exp: 0, gold: 0, diamond: 0 } // ======================== 动画状态 ======================== /** 动画期间实时显示的分数(tween 驱动) */ private _displayScore: number = 0; /** 滚动前的历史最高分(动画起始值) */ private _prevBestScore: number = 0; /** 本局总分(动画结束值) */ private _finalScore: number = 0; /** 滚动起始段位(用于检测跨段) */ private _startRank: ScoreRankInfo = null!; /** 动画期间是否已触发过段位晋升动效 */ private _rankUpPlayed: boolean = false; // ======================== 复活相关 ======================== /** 是否可以复活(由 MissionComp 传入,取决于剩余复活次数) */ private canRevive: boolean = false; /** 加载时隐藏 loading 遮罩 */ protected onLoad(): void { this.node.getChildByName("loading").active = false } /** * 弹窗打开时的回调:接收战斗结果参数。 * * @param args.victory 是否胜利(当前仅用于标识) * @param args.rewards 掉落奖励列表 * @param args.game_data 累计数据 { exp, gold, diamond } * @param args.can_revive 是否可复活 */ onAdded(args: any) { this.node.getChildByName("loading").active = false mLogger.log(this.debugMode, 'VictoryComp', "[VictoryComp] onAdded", args) if (args.game_data) { this.game_data = args.game_data } // 根据是否可复活决定按钮显示 this.node.getChildByName("btns").getChildByName("next").active = !args.can_revive // 计算总分并写入历史纪录 this.calculateTotalScore(); // 每次战斗结束都标记数据脏并触发云端同步(防抖 3 秒后自动上传) smc.updateCloudData(); // 播放结算动画 this.playSettleAnimation(); // 渲染亮点标签 this.renderHighlights(); } // ======================== 分数计算(逻辑层,保持不变) ======================== /** * 获取满足条件的最高等级的亮点成就 * @param type 亮点类型 * @param value 玩家实际达成的值 * @returns 达成的最高等级配置,未达成返回 null */ private getHighestHighlightLevel(type: HighlightType, value: number): HighlightLevel | null { const config = HighlightSet[type]; if (!config || !config.levels) return null; let highest: HighlightLevel | null = null; for (const levelConfig of config.levels) { if (value >= levelConfig.threshold) { highest = levelConfig; } } return highest; } /** * 计算并获取所有达成的最高亮点配置数组,用于加分和UI展示 */ private getAchievedHighlights(s: any): { type: HighlightType, config: HighlightLevel, value: number }[] { const achieved: { type: HighlightType, config: HighlightLevel, value: number }[] = []; // 计算辅助比例 const refreshRatio = s.refresh_count > 0 ? (s.refresh_hit_count / s.refresh_count) : 0; const goldRatio = s.gold_earned > 0 ? (s.gold_spent / s.gold_earned) : 0; // 判定表:每个维度对应的值 const checkList: { type: HighlightType, value: number }[] = [ { type: HighlightType.CritMaster, value: s.crt_count }, { type: HighlightType.DeathExpert, value: s.dead_trigger_count }, { type: HighlightType.IronWall, value: s.shield_block_count }, { type: HighlightType.WindStorm, value: s.wf_count }, { type: HighlightType.OneHitKill, value: Math.floor(s.highest_dmg) }, { type: HighlightType.HealingLight, value: Math.floor(s.heal_total) }, { type: HighlightType.PerfectClear, value: (s.passed_wave_20 && s.wave_all_alive_count >= 20) ? 1 : 0 }, { type: HighlightType.LuckyKing, value: refreshRatio }, { type: HighlightType.ThriftyPlayer, value: goldRatio } ]; for (const item of checkList) { const levelConfig = this.getHighestHighlightLevel(item.type, item.value); if (levelConfig) { achieved.push({ type: item.type, config: levelConfig, value: item.value }); } } return achieved; } /** * 计算单局总分并更新到 smc.vmdata.scores.score。 * 同时刷新 smc.data.score 历史最高分。 */ private calculateTotalScore() { const s = smc.vmdata.scores; // 1. 战绩分:衡量生存能力——活几回合、赢几场。 s.score_combat = (s.wave_win_count * 100) - (s.wave_remain_monsters * 15) + (s.wave_all_alive_count * 50) + (s.passed_wave_20 ? 500 : 0); // 2. 输出分:衡量伤害能力——团队火力如何。 s.score_output = Math.floor(s.total_dmg / 100) * 10 + (s.crt_count * 5) + (s.wf_count * 5) + (s.highest_dmg * 2); // 3. 防御分:衡量生存能力——团队多能扛。 s.score_defense = (s.shield_block_count * 5) + Math.floor(s.heal_total / 50) * 10 + (s.dead_trigger_count * 8); // 4. 构建分:衡量阵容构建质量——团队配合程度。 s.score_build = 0; // 待完善 // 5. 效率分:衡量资源利用——金币花得值不值。 const goldRatio = s.gold_earned > 0 ? (s.gold_spent / s.gold_earned) : 0; const refreshRatio = s.refresh_count > 0 ? (s.refresh_hit_count / s.refresh_count) : 0; s.score_efficiency = Math.floor(goldRatio * 100) + Math.floor(refreshRatio * 50); // 6. 亮点成就额外加分 (按等级叠加) const achieved = this.getAchievedHighlights(s); s.achieved_highlights = achieved; // 记录已达成的亮点信息 let highlightBonus = 0; for (const item of achieved) { highlightBonus += item.config.scoreBonus; } // 取整并存储当前局分数 s.score = Math.floor(s.score_combat + s.score_output + s.score_defense + s.score_build + s.score_efficiency + highlightBonus); // 记录滚动前的历史最高分(动画起始值) this._prevBestScore = smc.data.score; this._finalScore = s.score; // 判定是否打破历史最高分记录 let isNewRecord = false; if (s.score > smc.data.score) { smc.data.score = s.score; isNewRecord = true; // 更新云端/本地存储,保存新记录 if (typeof smc.updateCloudData === "function") { smc.updateCloudData(); } } // 借用 scores 对象传递新记录标记,供 UI 渲染使用 (s as any).isNewRecord = isNewRecord; mLogger.log(this.debugMode, 'VictoryComp', `[VictoryComp] 结算总分: ${s.score}`, { combat: s.score_combat, output: s.score_output, defense: s.score_defense, build: s.score_build, efficiency: s.score_efficiency, highlightBonus: highlightBonus, achievedHighlights: achieved, isNewRecord: isNewRecord, prevBest: this._prevBestScore }); } // ======================== 结算动画 ======================== /** * 播放结算动画:入场 → 总分滚动 → 定格涨幅 */ private playSettleAnimation() { // 初始化动画状态 this._displayScore = this._prevBestScore; this._startRank = getRankByScore(this._prevBestScore); this._rankUpPlayed = false; // 初始渲染:旧纪录分数与段位 this.renderScore(this._prevBestScore); this.renderRank(this._prevBestScore); // 隐藏涨幅标签 if (this.gain_label) { this.gain_label.node.active = false; } // 若本局分数未超过旧纪录,直接定格,无需滚动 if (this._finalScore <= this._prevBestScore) { this.showGainLabel(); return; } // 滚动动画:从旧纪录涨到本局分数 const self = this; const proxy = { value: this._prevBestScore }; tween(proxy) .to(SCORE_TWEEN_DURATION, { value: this._finalScore }, { easing: 'quadOut', onUpdate(target) { self.onScoreTweenUpdate(Math.floor(target!.value)); } }) .call(() => { self.onScoreTweenEnd(); }) .start(); } /** * 总分滚动过程中的每一帧更新 * @param score 当前插值分数 */ private onScoreTweenUpdate(score: number) { this._displayScore = score; this.renderScore(score); this.renderRank(score); // 跨段检测:当前分数已超过起始段位的下一段门槛,触发晋升动效 const currentRank = getRankByScore(score); if (!this._rankUpPlayed && currentRank.tier !== this._startRank.tier) { this._rankUpPlayed = true; this.playRankUpEffect(); } } /** * 总分滚动结束 */ private onScoreTweenEnd() { this._displayScore = this._finalScore; this.renderScore(this._finalScore); this.renderRank(this._finalScore); this.showGainLabel(); } /** * 渲染总分数字 */ private renderScore(score: number) { if (this.total_score_label) { this.total_score_label.string = `${score}`; } } /** * 渲染段位徽章、进度条与下一段位提示 * @param score 当前用于判定段位的分数 */ private renderRank(score: number) { const rank = getRankByScore(score); const next = getNextRank(score); // 段位徽章:从全局图集取帧 if (this.rank_icon && smc.uiconsAtlas) { const frame = smc.uiconsAtlas.getSpriteFrame(rank.icon); if (frame) { this.rank_icon.spriteFrame = frame; } } // 段位进度条 if (this.rank_progress) { this.rank_progress.progress = getRankProgress(score); } // 下一段位提示 if (this.next_rank_label) { this.next_rank_label.string = next ? `下一段位:${next.name}(${next.minScore} 分)` : `已达最高段位`; } } /** * 段位晋升动效:徽章放大回弹 + 闪白 */ private playRankUpEffect() { if (!this.rank_icon) return; const node = this.rank_icon.node; // 停止徽章上残留的 tween,避免叠加 Tween.stopAllByTarget(node); node.setScale(1, 1, 1); tween(node) .to(RANK_UP_DURATION * 0.4, { scale: v3(1.4, 1.4, 1) }, { easing: 'quadOut' }) .to(RANK_UP_DURATION * 0.6, { scale: v3(1, 1, 1) }, { easing: 'backOut' }) .start(); // 若有 UIOpacity 组件则做一次闪白 const op = node.getComponent(UIOpacity); if (op) { Tween.stopAllByTarget(op); tween(op) .to(RANK_UP_DURATION * 0.3, { opacity: 120 }) .to(RANK_UP_DURATION * 0.7, { opacity: 255 }) .start(); } } /** * 滚动定格后显示 +N 涨幅标签(仅破纪录时) */ private showGainLabel() { if (!this.gain_label) return; const s = smc.vmdata.scores; const isNewRecord = (s as any).isNewRecord === true; const gain = this._finalScore - this._prevBestScore; if (!isNewRecord || gain <= 0) { this.gain_label.node.active = false; return; } this.gain_label.string = `+${gain} 新纪录!`; this.gain_label.node.active = true; // 浮现动效:从 0 缩放淡入 const node = this.gain_label.node; Tween.stopAllByTarget(node); node.setScale(0, 0, 1); let op = node.getComponent(UIOpacity); if (!op) { op = node.addComponent(UIOpacity); } op.opacity = 0; tween(node) .to(0.35, { scale: v3(1, 1, 1) }, { easing: 'backOut' }) .start(); tween(op) .to(0.35, { opacity: 255 }) .start(); } // ======================== 亮点成就 ======================== /** * 根据当前局数据匹配并生成对应的亮点标签(成就) */ private renderHighlights() { if (!this.highlights_container || !this.highlight_prefab) return; // 先清空原有的标签 this.highlights_container.removeAllChildren(); const s = smc.vmdata.scores; // 获取所有已达成的亮点(包含对应等级的信息) const achievedList = s.achieved_highlights || []; // 最多显示前3个亮点(如有优先级需求,可在截取前对 achievedList 进行排序) const displayTags = achievedList.slice(0, 3); displayTags.forEach(item => { const tagNode = instantiate(this.highlight_prefab); const lab = tagNode.getComponent(Label) || tagNode.getChildByName("label")?.getComponent(Label); if (lab) { const typeConfig = HighlightSet[item.type]; const levelConfig = item.config; const uuid = levelConfig.uuid; const titleStr = lang(LangPrefix.hl_title, uuid); const descStr = langf(LangPrefix.hl_desc, uuid, levelConfig.threshold); lab.string = `${typeConfig.icon} ${titleStr} (${descStr})`; } this.highlights_container.addChild(tagNode); }); } // ======================== 操作入口 ======================== /** 退出战斗:清理数据 → 触发任务结束 → 关闭弹窗 */ victory_end() { this.clear_data() oops.message.dispatchEvent(GameEvent.MissionEnd) oops.gui.removeByNode(this.node) } /** 清理运行时数据:解除暂停标志 */ clear_data() { smc.mission.pause = false } /** 看广告双倍奖励(预留) */ watch_ad() { return true } /** 双倍奖励发放(预留) */ double_reward() { } /** * 重新开始: * 1. 清理数据。 * 2. 触发 MissionEnd 事件重置状态。 * 3. 显示 loading 遮罩,延迟 0.5 秒后触发 MissionStart。 * 4. 关闭弹窗。 */ restart() { this.clear_data() oops.message.dispatchEvent(GameEvent.MissionEnd) this.node.getChildByName("loading").active = true this.scheduleOnce(() => { oops.gui.open(UIID.Mission) this.node.getChildByName("loading").active = false oops.gui.removeByNode(this.node) }, 0.5) } /** 物品展示回调(预留) */ item_show(e: any, val: any) { mLogger.log(this.debugMode, 'VictoryComp', "item_show", val) } protected onDestroy(): void { // 停止所有动画,防止节点销毁后 tween 回调访问无效对象 if (this.rank_icon) Tween.stopAllByTarget(this.rank_icon.node); if (this.gain_label) Tween.stopAllByTarget(this.gain_label.node); super.onDestroy(); mLogger.log(this.debugMode, 'VictoryComp', "释放胜利界面"); } /** ECS 组件移除时销毁节点 */ reset() { this.node.destroy() } }