Files
pixelheros/assets/script/game/map/VictoryComp.ts
panFD 883fb56808 feat(victory-comp): 为胜利界面添加装饰节点并优化UI布局
1.  在VictoryComp组件中新增fireworks和halo装饰节点的序列化属性
2.  重构胜利界面预制体的节点结构,替换原有文本显示为装饰特效
3.  调整界面元素的位置、尺寸和样式,新增进度条与粒子特效节点
4.  更新预制体关联的资源引用与组件配置
2026-08-04 00:01:35 +08:00

534 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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();
// 播放结算动画
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()
}
}