537 lines
19 KiB
TypeScript
537 lines
19 KiB
TypeScript
/**
|
||
* @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()
|
||
}
|
||
}
|