feat(ui): 新增段位系统并重构结算弹窗

新增玩家得分段位配置表与相关工具函数,重构战斗结算弹窗:
1.  新增ScoreRankSet.ts实现段位判定、进度计算逻辑
2.  为结算界面添加段位徽章、进度条和晋升动效
3.  实现总分滚动动画与破纪录涨幅显示
4.  调整UI5图集的边框参数适配新UI资源
This commit is contained in:
panFD
2026-08-03 22:43:38 +08:00
parent 3fe6325830
commit ae24d0378a
8 changed files with 3112 additions and 9676 deletions

View File

@@ -2,57 +2,60 @@
* @file VictoryComp.ts
* @description 战斗结算弹窗组件UI 视图层)
*
* 通过 oops.gui.open(UIID.Victory, args) 打开。
* 展示段位徽章、总分滚动动画、段位进度与亮点成就,并提供重开 / 退出操作。
*/
import { _decorator, instantiate, Label, Prefab, Node, Sprite, Animation, AnimationClip, resources, UITransform, Widget, ProgressBar, Tween, NodeEventType } from "cc";
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 { HeroAttrsComp } from "../hero/HeroAttrsComp";
import { FacSet } from "../common/config/GameSet";
import { HeroInfo } from "../common/config/heroSet";
import { CKind, CardType, CardConfig } from "../common/config/CardSet";
import { CardComp } from "./CardComp";
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 —— 战斗结算弹窗视图组件
*
* 通过 oops.gui.open(UIID.Victory, args) 打开。
* 展示战斗结果,计算总分,并提供重开 / 退出操作。
* 布局(自上而下):
* 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 {
@property(Node)
mvp_node = null!
// ======================== 结算 UI 绑定 ========================
@property({ type: Label, tooltip: "总分文本" })
@property({ type: Sprite, tooltip: "段位徽章图标rank_icon 节点上的 Sprite" })
rank_icon: Sprite = null!;
@property({ type: Label, tooltip: "总分文本total/score_label" })
total_score_label: Label = null!;
@property({ type: Node, tooltip: "战绩分节点 (需包含 score_label 和 progress_bar 子节点)" })
combat_node: Node = null!;
@property({ type: ProgressBar, tooltip: "段位进度条rank_progress" })
rank_progress: ProgressBar = null!;
@property({ type: Node, tooltip: "输出分节点 (需包含 score_label 和 progress_bar 子节点)" })
output_node: Node = null!;
@property({ type: Label, tooltip: "下一段位提示文本next_rank_label" })
next_rank_label: Label = null!;
@property({ type: Node, tooltip: "防御分节点 (需包含 score_label 和 progress_bar 子节点)" })
defense_node: Node = null!;
@property({ type: Node, tooltip: "构建分节点 (需包含 score_label 和 progress_bar 子节点)" })
build_node: Node = null!;
@property({ type: Node, tooltip: "效率分节点 (需包含 score_label 和 progress_bar 子节点)" })
efficiency_node: Node = null!;
@property({ type: Label, tooltip: "涨幅标签gain_label仅破纪录时显示" })
gain_label: Label = null!;
@property({ type: Node, tooltip: "亮点成就标签的容器" })
highlights_container: Node = null!;
@@ -75,6 +78,19 @@ export class VictoryComp extends CCComp {
diamond: 0
}
// ======================== 动画状态 ========================
/** 动画期间实时显示的分数tween 驱动) */
private _displayScore: number = 0;
/** 滚动前的历史最高分(动画起始值) */
private _prevBestScore: number = 0;
/** 本局总分(动画结束值) */
private _finalScore: number = 0;
/** 滚动起始段位(用于检测跨段) */
private _startRank: ScoreRankInfo = null!;
/** 动画期间是否已触发过段位晋升动效 */
private _rankUpPlayed: boolean = false;
// ======================== 复活相关 ========================
/** 是否可以复活(由 MissionComp 传入,取决于剩余复活次数) */
@@ -103,105 +119,17 @@ export class VictoryComp extends CCComp {
// 根据是否可复活决定按钮显示
this.node.getChildByName("btns").getChildByName("next").active = !args.can_revive
// 计算总分
// 计算总分并写入历史纪录
this.calculateTotalScore();
// 渲染分数UI和亮点标签
this.renderScores();
// 播放结算动画
this.playSettleAnimation();
// 显示MVP英雄
const mvp = this.getMVPHero();
this.renderMVPHero(mvp);
// 渲染亮点标签
this.renderHighlights();
}
// ======================== MVP 英雄 ========================
/**
* 获取战斗中最厉害的英雄(根据等级和攻击力)
*/
private getMVPHero(): HeroAttrsComp | null {
let mvp: HeroAttrsComp | null = null;
ecs.query(ecs.allOf(HeroAttrsComp)).forEach((entity: ecs.Entity) => {
const model = entity.get(HeroAttrsComp);
if (!model || model.fac !== FacSet.HERO) return;
if (!mvp) {
mvp = model;
} else {
if (model.lv > mvp.lv) {
mvp = model;
} else if (model.lv === mvp.lv && model.ap > mvp.ap) {
mvp = model;
}
}
});
return mvp;
}
/**
* 渲染 MVP 英雄,使用绑定的 CardComp
*/
private renderMVPHero(mvp: HeroAttrsComp | null) {
if (!this.mvp_node) return;
if (!mvp) {
this.mvp_node.active = false;
return;
}
this.mvp_node.active = true;
const uuid = mvp.hero_uuid;
const hero = HeroInfo[uuid];
if (!hero) return;
// 延迟到下一帧执行,因为如果 mvp_node 刚被激活Cocos 会在当前帧稍后/下一帧触发 CardComp 的 onLoad
// 而 CardComp 的 onLoad 中调用了 applyEmptyUI() 会把卡面清空,导致我们的设置被覆盖。
this.scheduleOnce(() => {
if (!this.isValid || !this.mvp_node) return;
// 获取 CardComp 组件
let cardComp = this.mvp_node.getComponent(CardComp);
if (!cardComp) {
cardComp = this.mvp_node.addComponent(CardComp);
}
// 构造虚拟的 CardConfig 数据供渲染
const cardConfig: CardConfig = {
uuid: uuid,
type: CardType.Hero,
cost: 0,
weight: 0,
kind: CKind.Hero,
card_lv: mvp.card_lv || 1,
hero_lv: mvp.lv || 1,
base_card_lv: mvp.base_card_lv || mvp.card_lv || 1,
};
const originalPos = this.mvp_node.position.clone();
// 禁用交互事件,防止结算界面的卡牌被拖拽或点击
this.mvp_node.off(NodeEventType.TOUCH_START);
this.mvp_node.off(NodeEventType.TOUCH_MOVE);
this.mvp_node.off(NodeEventType.TOUCH_END);
this.mvp_node.off(NodeEventType.TOUCH_CANCEL);
if (cardComp.Lock) cardComp.Lock.off(NodeEventType.TOUCH_END);
if (cardComp.unLock) cardComp.unLock.off(NodeEventType.TOUCH_END);
// 应用数据并刷新UI
cardComp.applyDrawCard(cardConfig);
// 隐藏不必要的信息(比如费用)
if (cardComp.cost_node) {
cardComp.cost_node.active = false;
}
// 覆盖 CardComp 内部动画的 scale停止其上的 tween并直接放大
// 结算界面的卡牌需要放大显示170 * 1.35 ≈ 230
Tween.stopAllByTarget(this.mvp_node);
this.mvp_node.setPosition(originalPos);
this.mvp_node.setScale(1.35, 1.35, 1);
}, 0);
}
// ======================== 分数计算(逻辑层,保持不变) ========================
/**
* 获取满足条件的最高等级的亮点成就
@@ -257,6 +185,7 @@ export class VictoryComp extends CCComp {
/**
* 计算单局总分并更新到 smc.vmdata.scores.score。
* 同时刷新 smc.data.score 历史最高分。
*/
private calculateTotalScore() {
const s = smc.vmdata.scores;
@@ -298,6 +227,10 @@ export class VictoryComp extends CCComp {
// 取整并存储当前局分数
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) {
@@ -320,53 +253,184 @@ export class VictoryComp extends CCComp {
efficiency: s.score_efficiency,
highlightBonus: highlightBonus,
achievedHighlights: achieved,
isNewRecord: isNewRecord
isNewRecord: isNewRecord,
prevBest: this._prevBestScore
});
}
// ======================== 结算动画 ========================
/**
* 渲染得分条与亮点标签
* 依赖各维度对应的UI节点需要在Cocos Creator中拖入绑定
* 播放结算动画:入场 → 总分滚动 → 定格涨幅
*/
private renderScores() {
const s = smc.vmdata.scores;
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 = `${s.score}`;
this.total_score_label.string = `${score}`;
}
}
// 判定是否是新记录,如果是则激活 new 节点
const isNewRecord = (s as any).isNewRecord === true;
const newNode = this.total_score_label.node.getChildByName("new");
if (newNode) {
newNode.active = isNewRecord;
/**
* 渲染段位徽章、进度条与下一段位提示
* @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;
}
}
// 通用渲染单个维度的函数
const renderDim = (node: Node, score: number, maxScore: number) => {
if (!node) return;
const lab = node.getChildByName("score_label")?.getComponent(Label);
if (lab) lab.string = `${score}`;
// 段位进度条
if (this.rank_progress) {
this.rank_progress.progress = getRankProgress(score);
}
const bar = node.getChildByName("progress_bar")?.getComponent(ProgressBar);
if (bar) {
// 根据该维度得分占“预期满分”的比例设置进度条fillRange
bar.progress = Math.min(1, Math.max(0, score / maxScore));
}
};
// TODO: 进度条的最大值可按设计期望自行调整,目前为占位预估值
// renderDim(this.combat_node, s.score_combat, 3000);
// renderDim(this.output_node, s.score_output, 3000);
// renderDim(this.defense_node, s.score_defense, 1500);
// renderDim(this.build_node, s.score_build, 1000);
// renderDim(this.efficiency_node, s.score_efficiency, 200);
// 渲染成就亮点标签
this.renderHighlights();
// 下一段位提示
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();
}
// ======================== 亮点成就 ========================
/**
* 根据当前局数据匹配并生成对应的亮点标签(成就)
*/
@@ -449,6 +513,9 @@ export class VictoryComp extends CCComp {
}
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', "释放胜利界面");
}
@@ -457,4 +524,4 @@ export class VictoryComp extends CCComp {
reset() {
this.node.destroy()
}
}
}